Q1K3

First-person Quake-style FPS, 3 weapons, 5 enemy AI states.

📄 Docs only

Upstream: https://github.com/phoboslab/q1k3

Tags: port tier-2

Q1K3: SimVX port

3D first-person shooter ported from phoboslab/q1k3 (MIT, js13k 2021 entry, 13KB Quake-clone).

Run

# Interactive (Vulkan window, mouse-capture FPS controls)
uv run python ported_games/q1k3/simvx_port/main.py

# Headless screenshot capture (8 stages)
uv run python ported_games/q1k3/simvx_port/main.py --test

# Web export
uv run simvx export web ported_games/q1k3/simvx_port/main.py \
    -o ported_games/q1k3/simvx_port/web/index.html

Controls

  • WASD or arrows: move

  • Mouse: look

  • LMB: fire

  • SPACE or RMB: jump

  • Q / E or mousewheel: switch weapon

  • ESC: quit

See also

  • PLAN.md: port plan

  • NOTES.md: engine friction, deviations, gaps

Source

  1"""Q1K3: First-person Quake-style FPS, 3 weapons, 5 enemy AI states.
  2
  3# /// simvx
  4# tags = ["port", "tier-2"]
  5# upstream = "https://github.com/phoboslab/q1k3"
  6# web = { width = 1280, height = 720, responsive = true, disabled = true, reason = "Blocked on audio refactor (uses removed AudioStream.from_pcm(sample_rate=…) kwarg)." }
  7# ///
  8
  9Run:
 10    uv run python ported_games/q1k3/simvx_port/main.py            # interactive
 11    uv run python ported_games/q1k3/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.root import Q1K3Root  # noqa: E402
 25
 26WIDTH = 1280
 27HEIGHT = 720
 28
 29
 30def _run_headless() -> None:
 31    """Capture a sequence of stage screenshots in one App.run_headless call."""
 32    from simvx.graphics import App, save_png
 33
 34    out_dir = _PORT_DIR / "screenshots"
 35    out_dir.mkdir(exist_ok=True)
 36
 37    # Each tuple = (name, frame_idx, mutator(root) -> None)
 38    boot_settle = 4
 39
 40    def stage_at(name: str, frame: int, fn=None):
 41        return (name, frame, fn or (lambda r: None))
 42
 43    # Schedule 8 stages.
 44    # Coordinates: cells xz=32 units, y=16 units. yaw=0 looks +Z (toward doorway).
 45    # Start room interior cells x=11..22, z=11..22. Corridor x=16..17, z=24..31.
 46    # Locked-door room z=32..41.
 47    Vec3 = type((lambda: __import__("simvx.core", fromlist=["Vec3"]).Vec3)())
 48    from simvx.core import Vec3 as _V
 49
 50    stages = []
 51    stages.append(stage_at("01_spawn.png", boot_settle - 1))
 52
 53    def view_door(root):
 54        if root.player:
 55            root.player.p = _V(16 * 32 + 16, 24, 14 * 32)
 56            root.player.position = root.player.p
 57            root.player._yaw = 0.0  # JS-frame yaw=0 → face +Z (toward enemies, doorway, door)
 58            root.player._pitch = 0.0
 59    stages.append(stage_at("02_room_interior.png", boot_settle + 4, view_door))
 60
 61    def look_corridor_enemy(root):
 62        # Place player IN the corridor, just past the lintel, and the grunt
 63        # close-ish ahead so the screenshot frames it clearly.
 64        if root.player:
 65            root.player.p = _V(16 * 32 + 16, 24, 25 * 32)
 66            root.player.position = root.player.p
 67            root.player._yaw = 0.0  # JS-frame yaw=0 → face +Z (toward enemies, doorway, door)
 68            root.player._pitch = 0.0
 69        # Place the grunt 2 cells ahead, centered, body facing the player.
 70        for e in root._enemies:
 71            if type(e).__name__ == "Grunt":
 72                e.p = _V(16 * 32 + 16, 24, 27 * 32)
 73                e.position = e.p
 74                e._target_yaw = 3.14159
 75                e._yaw = 3.14159
 76                e.v = _V(0, 0, 0)
 77                # Force IDLE so AI doesn't immediately rotate / move.
 78                e._set_state("IDLE")
 79                break
 80    stages.append(stage_at("03_enemy_in_sight.png", boot_settle + 8, look_corridor_enemy))
 81
 82    def fire_weapon(root):
 83        if root.player:
 84            # Player's own SFX/light flash is timed with the firing call;
 85            # advance _can_shoot_at and call weapon.shoot directly.
 86            root.player._can_shoot_at = -1.0
 87            root.player.weapon.shoot(root, root.player.p, root.player._yaw, root.player._pitch)
 88            # Also spawn the muzzle flash light (normally done in Player.on_process):
 89            root.spawn_temp_light(root.player.p + _V(0, 8, 0), 4.0, (1.0, 0.9, 0.3), 0.2)
 90    stages.append(stage_at("04_weapon_fire.png", boot_settle + 12, fire_weapon))
 91
 92    def kill_grunt(root):
 93        # Force kill the corridor grunt
 94        for e in list(root._enemies):
 95            if hasattr(e, "_kill") and not getattr(e, "_dead", False):
 96                e._kill()
 97                break
 98    stages.append(stage_at("05_enemy_killed.png", boot_settle + 16, kill_grunt))
 99
100    def near_locked_door(root):
101        if root.player:
102            # Approach the locked door
103            root.player.p = _V(16 * 32 + 16, 24, 30 * 32)
104            root.player.position = root.player.p
105            root.player._yaw = 0.0  # JS-frame yaw=0 → face +Z (toward enemies, doorway, door)
106            root.player._pitch = 0.0
107    stages.append(stage_at("06_locked_door.png", boot_settle + 20, near_locked_door))
108
109    def have_key(root):
110        # Pretend we picked up the key; banner changes; door will open
111        root.has_key = True
112    stages.append(stage_at("07_door_unlocked.png", boot_settle + 24, have_key))
113
114    def cinematic(root):
115        if root.player:
116            # Stand inside the locked room, looking back at the door (door at z=32, room
117            # centre z=37). JS-frame yaw=π → look -Z toward the door.
118            root.player.p = _V(16 * 32 + 16, 24, 38 * 32)
119            root.player.position = root.player.p
120            root.player._yaw = math.pi
121            root.player._pitch = 0.0
122    stages.append(stage_at("08_locked_room.png", boot_settle + 28, cinematic))
123
124    capture_indices = sorted({s[1] for s in stages})
125    schedule = {s[1]: s[2] for s in stages}
126    total_frames = max(capture_indices) + 2
127
128    app = App(width=WIDTH, height=HEIGHT, title="Q1K3 (test)", visible=False)
129    root = Q1K3Root()
130    root._is_headless = True
131
132    def on_frame(idx, _t):
133        if idx in schedule:
134            try:
135                schedule[idx](root)
136            except Exception as e:
137                print(f"[--test] stage at frame {idx} failed: {e!r}")
138        return None
139
140    captured = app.run_headless(
141        root,
142        frames=total_frames,
143        capture_frames=capture_indices,
144        on_frame=on_frame,
145    )
146
147    # Match each capture index back to a stage name (multiple stages may share
148    # an index if scheduling collapses them; here they're distinct).
149    by_frame = {f: name for name, f, _ in stages}
150    for frame_idx, img in zip(capture_indices, captured, strict=False):
151        name = by_frame.get(frame_idx, f"frame_{frame_idx:03d}.png")
152        save_png(out_dir / name, img)
153        print(f"saved {out_dir / name}")
154
155
156def main() -> None:
157    if "--test" in sys.argv:
158        _run_headless()
159        return
160    from simvx.graphics import App
161    app = App(width=WIDTH, height=HEIGHT, title="Q1K3 (SimVX)")
162    app.run(Q1K3Root())
163
164
165if __name__ == "__main__":
166    main()