Skip to content

Instantly share code, notes, and snippets.

@minimalefforttech
Created September 21, 2025 10:03
Show Gist options
  • Select an option

  • Save minimalefforttech/051bd24e455bedd3312352432385df03 to your computer and use it in GitHub Desktop.

Select an option

Save minimalefforttech/051bd24e455bedd3312352432385df03 to your computer and use it in GitHub Desktop.
Doom Player Panel in Blender Icon
"""
Running Doom inside a Blender icon
Copyright (c) 2025 Alex Telford
License: GPLv3 or later
3rd Party Libraries:
- vizdoom: MIT License
- numpy: Modified BSD License (Bundled with Blender)
- pillow: HPND License
Product listing:
https://alextelford.gumroad.com/l/blender_doom_icon
This is a free product, no restrictions on use, but likewise the code is provided as-is, with no warranty.
Disclaimer:
This will crash at some point, either by consuming too much memory or vizdoom breaking when you die.
I just wanted to see if I could do it, it's not really a functional or useful product.
Instructions:
- Download freedoom from https://freedoom.github.io/download.html
- Extract the zip and note the path to freedoom1.wad
- Run this script in Blender, ideally a standalone version so it can install dependencies.
- Wait for 3rd party dependencies to install, this can take a bit.
- In the Scene Properties panel go to the "Doom Player" tab.
- Click "Enable Doom Player" to enable the player.
- Set the path to your freedoom1.wad file.
- Click "Start Game" to start the game.
- Click the arrows to move around.
Config
- The FPS controls the game update and speed, too high and it will flicker
- The Preflight controls how many frames to buffer ahead of time, more means less stutter the game will be that many frames behind.
Enjoy the insanity.
I've released this for free, if you want to support my shenanigans please consider buying one of my books:
Blender Tool Development Fundamentals (Pre-order)
https://jettelly.com/store/blender-tool-development-fundamentals?referral=atelford
Practical Python for Production under Pressure (Complete)
https://leanpub.com/practical_python
Or buying the listing for this script:
https://alextelford.gumroad.com/l/blender_doom_icon
But no pressure, it's free to use and modify as you wish.
"""
from __future__ import annotations
import os
import sys
import subprocess
import ensurepip
import tempfile
import shutil
import importlib
import traceback
from dataclasses import dataclass
from typing import Dict, List, Optional
import bpy
from bpy.utils import previews # Seems to be a bug in 4.5.3 portable where this is not in bpy.utils by default
from bpy.props import StringProperty, IntProperty, BoolProperty
from bpy.types import Panel, Operator, PropertyGroup
# MARK: Constants
FRAMES_PER_COLLECTION = 100
ICON_SCALE = 12
DEFAULT_WAD_PATH = ""
# DEFAULT_WAD_PATH = r"C:\\projects\\freedoom-0.13.0\\freedoom1.wad"
DEFAULT_MAP = "map01"
ESCAPE_TO_STOP = False # Set to True to allow ESC to stop the game
def _ensure_dependencies(dependencies):
"""
Ensure dependencies are installed for Blender's Python.
Args:
dependencies (list): List of dependencies, where each item is either:
- "package" (same for pip and import)
- ("pip_name", "import_name")
"""
# Normalize dependencies into (pip_name, import_name)
normalized = [(dep, dep) if isinstance(dep, str) else dep for dep in dependencies]
missing = []
for pip_name, import_name in normalized:
try:
importlib.import_module(import_name)
except ImportError:
missing.append((pip_name, import_name))
if not missing:
return
# Target Blender's site-packages explicitly, this prevents it being installed
# Into the system globals which can mess with things
site_packages = os.path.join(os.path.dirname(sys.executable), "..", "Lib", "site-packages")
site_packages = os.path.normpath(site_packages)
# Check site_packages exists and is writable
if not os.path.exists(site_packages) or not os.access(site_packages, os.W_OK):
pkgs = " ".join(p for p, _ in missing)
raise ImportError(
f"Need to install dependencies: {pkgs}\nBut cannot write to site-packages, try using the portable version"
)
try:
ensurepip.bootstrap()
for pip_name, _ in missing:
subprocess.check_call([sys.executable, "-m", "pip", "install", pip_name, "--target", site_packages])
# Verify imports after install
for _, import_name in missing:
importlib.import_module(import_name)
except Exception as e:
pkgs = " ".join(p for p, _ in missing)
raise ImportError(
f"Could not auto-install dependencies. "
f"Install manually with:\n"
f'"{sys.executable}" -m pip install {pkgs} --target "{site_packages}"'
) from e
_ensure_dependencies(["vizdoom", ("pillow", "PIL")])
import numpy as np # noqa: E402
import vizdoom as vzd # noqa: E402
from PIL import Image # noqa: E402
# MARK: Globals
preview_collections: Dict[str, previews.ImagePreviewCollection] = {}
PREFLIGHT_QUEUE: List[str] = []
_IS_UPDATING_MOVEMENT = False
ACTIVE_DOOM_WRANGLER: Optional["DoomWrangler"] = None
# MARK: Prop callbacks
def update_move_forward(self, context):
global _IS_UPDATING_MOVEMENT
if _IS_UPDATING_MOVEMENT:
return
if self.move_forward:
_IS_UPDATING_MOVEMENT = True
self.move_backward = False
_IS_UPDATING_MOVEMENT = False
def update_move_backward(self, context):
global _IS_UPDATING_MOVEMENT
if _IS_UPDATING_MOVEMENT:
return
if self.move_backward:
_IS_UPDATING_MOVEMENT = True
self.move_forward = False
_IS_UPDATING_MOVEMENT = False
def update_turn_left(self, context):
global _IS_UPDATING_MOVEMENT
if _IS_UPDATING_MOVEMENT:
return
if self.turn_left:
_IS_UPDATING_MOVEMENT = True
self.turn_right = False
_IS_UPDATING_MOVEMENT = False
def update_turn_right(self, context):
global _IS_UPDATING_MOVEMENT
if _IS_UPDATING_MOVEMENT:
return
if self.turn_right:
_IS_UPDATING_MOVEMENT = True
self.turn_left = False
_IS_UPDATING_MOVEMENT = False
# MARK: Properties
class PreflightFrameItem(bpy.types.PropertyGroup):
"""Preflight frame reference item"""
frame_id: StringProperty(name="Frame ID")
collection_name: StringProperty(name="Collection Name")
class DoomPlayerProperties(PropertyGroup):
"""Root property group for Doom player"""
# Config
fps: IntProperty(name="FPS", default=12, min=1, max=60)
preflight: IntProperty(name="Preflight Frames", default=2, min=0, max=30)
wad_path: StringProperty(
name="WAD File",
description="Path to the Doom WAD file",
default=DEFAULT_WAD_PATH,
maxlen=1024,
subtype="FILE_PATH",
)
current_map: StringProperty(name="Current Map", default=DEFAULT_MAP)
temp_dir: StringProperty(name="Temp Directory", default="")
is_enabled: BoolProperty(name="Enable Doom Player", description="Enable/disable the Doom player", default=False)
is_running: BoolProperty(name="Game Running", description="Whether the game is currently running", default=False)
use_keyboard: BoolProperty(name="Use Keyboard", default=False)
# Input
move_forward: BoolProperty(name="Move Forward", default=False, update=update_move_forward)
move_backward: BoolProperty(name="Move Backward", default=False, update=update_move_backward)
turn_left: BoolProperty(name="Turn Left", default=False, update=update_turn_left)
turn_right: BoolProperty(name="Turn Right", default=False, update=update_turn_right)
attack: BoolProperty(name="Attack", default=False)
use: BoolProperty(name="Use", default=False)
# State
frame_counter: IntProperty(name="Frame Counter", default=0)
current_frame_id: StringProperty(name="Current Frame ID", default="")
preflight_frames: bpy.props.CollectionProperty(type=bpy.types.PropertyGroup)
current_collection_index: IntProperty(name="Current Collection Index", default=0)
# MARK: Wrangler
@dataclass
class DoomState:
game: Optional[vzd.DoomGame] = None
initialized: bool = False
frame_id_counter: int = 0
class DoomWrangler:
def __init__(self, props: DoomPlayerProperties):
self.props = props
self.state = DoomState()
def initialize(self):
if self.state.initialized:
return
temp_dir = tempfile.mkdtemp(prefix="doom_frames_")
self.props.temp_dir = temp_dir
to_remove = [n for n in preview_collections if n.startswith("doom_frames_")]
for name in to_remove:
try:
previews.remove(preview_collections[name])
except Exception:
pass
preview_collections.pop(name, None)
game = vzd.DoomGame()
game.set_doom_game_path(self.props.wad_path)
game.set_doom_map(self.props.current_map)
game.set_window_visible(False)
game.set_mode(vzd.Mode.PLAYER)
game.set_screen_format(vzd.ScreenFormat.RGB24)
game.set_screen_resolution(vzd.ScreenResolution.RES_320X240)
game.set_render_hud(True)
game.set_render_minimal_hud(False)
game.set_render_crosshair(True)
game.set_render_weapon(True)
game.set_render_decals(True)
game.set_render_particles(True)
game.set_available_buttons(
[
vzd.Button.MOVE_FORWARD,
vzd.Button.MOVE_BACKWARD,
vzd.Button.TURN_LEFT,
vzd.Button.TURN_RIGHT,
vzd.Button.ATTACK,
vzd.Button.USE,
]
)
game.init()
self.state.game = game
self.state.initialized = True
def step(self):
if not self.state.initialized or not self.state.game:
self.initialize()
return
g = self.state.game
if g.is_episode_finished():
g.new_episode()
action = [
1 if self.props.move_forward else 0,
1 if self.props.move_backward else 0,
1 if self.props.turn_left else 0,
1 if self.props.turn_right else 0,
1 if self.props.attack else 0,
1 if self.props.use else 0,
]
g.make_action(action)
state = g.get_state()
if state and state.screen_buffer is not None:
self._update_frame(state)
def _update_frame(self, state):
buf = state.screen_buffer
if buf.ndim == 3 and buf.shape[0] in (3, 4):
img_array = np.transpose(buf, (1, 2, 0))
else:
img_array = buf
if img_array.ndim == 3:
img_pil = Image.fromarray(img_array.astype(np.uint8))
else:
img_pil = Image.fromarray(img_array.astype(np.uint8), mode="L")
frame_file = os.path.join(self.props.temp_dir, f"frame_{self.props.frame_counter:06d}.png")
img_pil.save(frame_file)
local_index = self.state.frame_id_counter // FRAMES_PER_COLLECTION
collection_name = f"doom_frames_{local_index}"
if collection_name not in preview_collections:
preview_collections[collection_name] = previews.new()
to_purge = []
for cname in preview_collections:
if cname.startswith("doom_frames_"):
idx = int(cname.split("_")[-1])
if idx < local_index - 1:
to_purge.append(cname)
for cname in to_purge:
try:
previews.remove(preview_collections[cname])
except Exception:
pass
preview_collections.pop(cname, None)
pcoll = preview_collections[collection_name]
frame_local_id = self.state.frame_id_counter % FRAMES_PER_COLLECTION
frame_id = f"frame_{frame_local_id:03d}"
pcoll.load(frame_id, frame_file, "IMAGE")
full_frame_id = f"{collection_name}:{frame_id}"
PREFLIGHT_QUEUE.append(full_frame_id)
max_queue = self.props.preflight * 2
if max_queue > 0 and len(PREFLIGHT_QUEUE) > max_queue:
del PREFLIGHT_QUEUE[: len(PREFLIGHT_QUEUE) - max_queue]
if self.props.preflight > 0 and len(PREFLIGHT_QUEUE) > self.props.preflight:
self.props.current_frame_id = PREFLIGHT_QUEUE[-(self.props.preflight + 1)]
else:
self.props.current_frame_id = PREFLIGHT_QUEUE[0]
self.state.frame_id_counter += 1
self.props.frame_counter += 1
def cleanup(self):
self.props.is_running = False
if self.state.game:
try:
self.state.game.close()
except Exception:
pass
self.state = DoomState()
to_remove = [n for n in preview_collections if n.startswith("doom_frames_")]
for name in to_remove:
try:
previews.remove(preview_collections[name])
except Exception:
pass
preview_collections.pop(name, None)
PREFLIGHT_QUEUE.clear()
if self.props.temp_dir and os.path.exists(self.props.temp_dir):
try:
shutil.rmtree(self.props.temp_dir, ignore_errors=True)
except Exception:
pass
self.props.temp_dir = ""
# MARK: Operators
class DOOM_OT_select_wad(Operator):
bl_idname = "doom.select_wad"
bl_label = "Browse WAD"
bl_description = "Select a Doom WAD file"
# filepath property bound after class
filepath: str
def execute(self, context):
props = context.scene.doom_player_props
props.wad_path = self.filepath
return {"FINISHED"}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
class DOOM_OT_toggle_game(Operator):
bl_idname = "doom.toggle_game"
bl_label = "Start/Stop Game"
bl_description = "Start or stop the Doom game"
def execute(self, context):
global ACTIVE_DOOM_WRANGLER
props = context.scene.doom_player_props
if props.is_running:
if ACTIVE_DOOM_WRANGLER:
ACTIVE_DOOM_WRANGLER.cleanup()
ACTIVE_DOOM_WRANGLER = None
self.report({"INFO"}, "Doom game stopped")
return {"FINISHED"}
if not props.is_enabled:
props.is_enabled = True
if not os.path.exists(props.wad_path):
self.report({"ERROR"}, f"WAD file not found: {props.wad_path}")
return {"CANCELLED"}
bpy.ops.doom.modal_game()
return {"FINISHED"}
class DOOM_OT_modal_game(Operator):
bl_idname = "doom.modal_game"
bl_label = "Doom Modal Game"
bl_description = "Modal operator handling Doom execution"
_timer = None
def modal(self, context, event):
global ACTIVE_DOOM_WRANGLER
props = context.scene.doom_player_props
if not props.is_enabled or not props.is_running or ACTIVE_DOOM_WRANGLER is None:
self.cleanup(context)
return {"CANCELLED"}
if event.type == "TIMER":
try:
ACTIVE_DOOM_WRANGLER.step()
for area in context.screen.areas:
if area.type in {"VIEW_3D", "PROPERTIES"}:
area.tag_redraw()
except Exception as e: # noqa: BLE001
print(f"Game error: {e}")
traceback.print_exc()
self.cleanup(context)
return {"CANCELLED"}
if event.type == "ESC" and ESCAPE_TO_STOP:
self.cleanup(context)
return {"CANCELLED"}
if props.use_keyboard and event.value == "PRESS":
if event.type in {"W", "UP_ARROW"}:
props.move_forward = True
elif event.type in {"S", "DOWN_ARROW"}:
props.move_backward = True
elif event.type in {"A", "LEFT_ARROW"}:
props.turn_left = True
elif event.type in {"D", "RIGHT_ARROW"}:
props.turn_right = True
elif event.type == "SPACE":
props.attack = True
elif event.type == "E":
props.use = True
else:
return {"PASS_THROUGH"}
return {"RUNNING_MODAL"}
elif props.use_keyboard and event.value == "RELEASE":
if event.type in {"W", "UP_ARROW"}:
props.move_forward = False
elif event.type in {"S", "DOWN_ARROW"}:
props.move_backward = False
elif event.type in {"A", "LEFT_ARROW"}:
props.turn_left = False
elif event.type in {"D", "RIGHT_ARROW"}:
props.turn_right = False
elif event.type == "SPACE":
props.attack = False
elif event.type == "E":
props.use = False
else:
return {"PASS_THROUGH"}
return {"RUNNING_MODAL"}
return {"PASS_THROUGH"}
def execute(self, context):
global ACTIVE_DOOM_WRANGLER
props = context.scene.doom_player_props
if props.is_running:
self.report({"WARNING"}, "Already running")
return {"CANCELLED"}
props.is_running = True
ACTIVE_DOOM_WRANGLER = DoomWrangler(props)
wm = context.window_manager
self._timer = wm.event_timer_add(1.0 / max(1, props.fps), window=context.window)
wm.modal_handler_add(self)
self.report({"INFO"}, "Doom game started")
return {"RUNNING_MODAL"}
def cleanup(self, context):
global ACTIVE_DOOM_WRANGLER
props = context.scene.doom_player_props
if self._timer:
try:
context.window_manager.event_timer_remove(self._timer)
except Exception:
pass
self._timer = None
if ACTIVE_DOOM_WRANGLER:
ACTIVE_DOOM_WRANGLER.cleanup()
ACTIVE_DOOM_WRANGLER = None
props.is_running = False
def cancel(self, context):
self.cleanup(context)
# MARK: UI
class DOOM_PT_player_panel(Panel):
bl_label = "Doom Player"
bl_idname = "DOOM_PT_player"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
def draw(self, context):
layout = self.layout
props = context.scene.doom_player_props
if not props.is_running:
box = layout.box()
box.label(text="Game Configuration", icon="SETTINGS")
row = box.row()
row.prop(props, "wad_path", text="")
row.operator("doom.select_wad", text="", icon="FILE_FOLDER")
box.prop(props, "current_map", text="Map")
box.prop(props, "fps")
box.prop(props, "preflight")
row = layout.row()
row.scale_y = 1.5
if props.is_running:
row.operator("doom.toggle_game", text="Stop Game", icon="SNAP_FACE")
elif props.is_enabled:
row.operator("doom.toggle_game", text="Start Game", icon="PLAY")
else:
row.prop(props, "is_enabled", toggle=True, text="Enable Doom Player", icon="PLAY")
if props.is_running and props.current_frame_id:
box = layout.box()
box.label(text="Game View", icon="RENDER_RESULT")
try:
collection_name, frame_id = props.current_frame_id.split(":")
pcoll = preview_collections.get(collection_name)
if pcoll and frame_id in pcoll:
row = box.row()
row.alignment = "CENTER"
row.template_icon(icon_value=pcoll[frame_id].icon_id, scale=ICON_SCALE)
except Exception:
pass
box = layout.box()
box.label(text="Input Controls", icon="KEYINGSET")
grid = box.grid_flow(columns=3, row_major=True, even_columns=True, even_rows=True, align=True)
grid.label(text="")
grid.prop(props, "move_forward", toggle=True, icon="TRIA_UP", text="")
grid.label(text="")
grid.prop(props, "turn_left", toggle=True, icon="TRIA_LEFT", text="")
grid.prop(props, "move_backward", toggle=True, icon="TRIA_DOWN", text="")
grid.prop(props, "turn_right", toggle=True, icon="TRIA_RIGHT", text="")
col = box.column(align=True)
row2 = col.row(align=True)
row2.prop(props, "attack", toggle=True, icon="OUTLINER_OB_LIGHTPROBE")
row2.prop(props, "use", toggle=True, icon="HAND")
col.separator(type="LINE")
col.prop(props, "use_keyboard", text="Hijack Keyboard Input")
if PREFLIGHT_QUEUE:
box = layout.box()
display_frames = (
PREFLIGHT_QUEUE[-props.preflight :]
if props.preflight > 0 and len(PREFLIGHT_QUEUE) > props.preflight
else PREFLIGHT_QUEUE
)
box.label(text="Frame Preflight", icon="SEQUENCE")
flow = box.grid_flow(
columns=min(8, len(display_frames)), row_major=True, even_columns=True, even_rows=True, align=True
)
for frame_ref in display_frames:
if ":" in frame_ref:
cname, fid = frame_ref.split(":")
pcoll = preview_collections.get(cname)
if pcoll and fid in pcoll:
flow.template_icon(icon_value=pcoll[fid].icon_id, scale=0.1)
# MARK: Registration
classes = (
PreflightFrameItem,
DoomPlayerProperties,
DOOM_OT_select_wad,
DOOM_OT_toggle_game,
DOOM_OT_modal_game,
DOOM_PT_player_panel,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.doom_player_props = bpy.props.PointerProperty(type=DoomPlayerProperties)
DOOM_OT_select_wad.filepath = StringProperty(subtype="FILE_PATH")
def unregister():
global ACTIVE_DOOM_WRANGLER
if ACTIVE_DOOM_WRANGLER:
try:
ACTIVE_DOOM_WRANGLER.cleanup()
except Exception:
pass
ACTIVE_DOOM_WRANGLER = None
for pcoll in list(preview_collections.values()):
try:
previews.remove(pcoll)
except Exception:
pass
preview_collections.clear()
if hasattr(bpy.types.Scene, "doom_player_props"):
try:
props = bpy.context.scene.doom_player_props
if props and props.temp_dir and os.path.exists(props.temp_dir):
shutil.rmtree(props.temp_dir, ignore_errors=True)
except Exception:
pass
del bpy.types.Scene.doom_player_props
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment