When I first set out to create my MOBA Template project in Unreal Engine 5, I knew I wanted it to do more than just run. I wanted it to build itself.
That’s when I started experimenting with Unreal’s Python scripting system—except, I wasn’t writing the Python code myself.
Instead, I was prompting it into existence using ChatGPT.
The first successful script I generated was one that spawned a turret actor (BP_BaseTurret_perception) at the location of a placeholder Blueprint (BP_TurretSpawnPoint). That might not sound like much, but this was the turning point.
Here’s how the logic flowed:
I described the problem in a natural prompt.
I provided the full asset path for both the turret and the spawn marker.
I clarified that the script needed to search for all spawn marker actors in the scene.
The AI generated a working Python script using Unreal’s Editor Scripting APIs.
With just a few small corrections and context nudges from me, the result was a script that worked. The turret appeared exactly where it should—and from that moment forward, everything clicked.
The trick wasn’t just in the prompt. It was in the context I gave the AI. I made sure to:
Reuse the same terminology I was using in my Blueprints.
Explain how my Blueprints were structured and named.
Clarify what each variable or exposed field was intended to do.
Describe the purpose of the script—not just the code I wanted.
This is what I’ve since come to understand as Context Engineering.
By keeping the conversation tightly focused and by referencing prior Blueprint structures, I was able to get more and more useful outputs from ChatGPT—including scripts that would:
Attach lanes to spawners.
Spawn player characters with auto-possess logic.
Assign public enum values during runtime.
Automatically place NavMeshBoundsVolumes based on the level layout.
Eventually, the project evolved into a modular, prompt-driven setup, where I could control gameplay logic, AI behavior, and even level design through simple prompts and a reusable Python toolkit.
Now, when I add new Blueprint types or systems, I only need to write a short description and my assistant can generate a Python script to handle the logic for me.
This workflow has been transformative—not just in how I build games, but in how I think about designing systems that can design themselves.
You can grab the Free Version of the MOBA Template on my site here:
👉 Elcade Store
If you want to go deeper and learn how to build your own prompt-driven game dev pipeline, I’ve also started putting together a [prompt-based eBook + toolkit] that will walk you through everything I’ve learned so far.
Prompting isn't just about making ChatGPT give you code. It’s about teaching it how to think like you—giving it just enough context so it understands your project as deeply as you do.
The Turret Spawning Script was the beginning of that journey. And from there, the rest of the game started building itself.
More to come soon.
—Estevan
import unreal
from math import sqrt
# ------------ CONFIG ------------
MARKER_PATH = "/Game/PlaceHolders/BP_TurretSpawnPoint.BP_TurretSpawnPoint"
TURRET_BLUEPRINT_PATH = "/Game/AutoTurretAsset/TurretAssetFiles/Blueprints/BaseTurret_perception.BaseTurret_perception"
# Add this tag to every spawned turret so we can detect duplicates later
SPAWN_TAG = "SpawnedByScript"
# How close (in cm) an existing tagged turret can be before we skip spawning another one
DUPLICATE_RADIUS = 50.0
# Lift the spawn location slightly to help with collision on uneven ground
Z_LIFT = 5.0
# --------------------------------
def v_dist(a: unreal.Vector, b: unreal.Vector) -> float:
return sqrt((a.x-b.x)**2 + (a.y-b.y)**2 + (a.z-b.z)**2)
# Load classes
marker_class = unreal.EditorAssetLibrary.load_blueprint_class(MARKER_PATH)
turret_class = unreal.EditorAssetLibrary.load_blueprint_class(TURRET_BLUEPRINT_PATH)
if not marker_class:
unreal.log_error(f"❌ Could not load marker class: {MARKER_PATH}")
if not turret_class:
unreal.log_error(f"❌ Could not load turret class: {TURRET_BLUEPRINT_PATH}")
if marker_class and turret_class:
world = unreal.EditorLevelLibrary.get_editor_world()
# Get all markers via GameplayStatics to match by class (includes child classes)
markers = unreal.GameplayStatics.get_all_actors_of_class(world, marker_class)
if not markers:
unreal.log_warning("⚠️ No BP_TurretSpawnPoint markers found in the current level.")
else:
# Gather existing turrets we previously spawned (by tag), to avoid duplicates
all_actors = unreal.EditorLevelLibrary.get_all_level_actors()
existing_spawned_turrets = [
a for a in all_actors
if SPAWN_TAG in [str(t) for t in a.tags]
]
spawned_count = 0
for marker in markers:
loc = marker.get_actor_location()
rot = marker.get_actor_rotation()
# Optional small Z lift to reduce spawn collision
loc = unreal.Vector(loc.x, loc.y, loc.z + Z_LIFT)
# Skip if a tagged turret already exists at (about) this location
if any(v_dist(a.get_actor_location(), loc) <= DUPLICATE_RADIUS for a in existing_spawned_turrets):
unreal.log(f"ℹ️ Skipped duplicate spawn near {loc} (found existing tagged turret).")
continue
turret = unreal.EditorLevelLibrary.spawn_actor_from_class(
turret_class,
loc,
rot
)
if turret:
# Add a tag so future runs can detect duplicates
new_tags = list(turret.tags)
if SPAWN_TAG not in [str(t) for t in new_tags]:
new_tags.append(unreal.Name(SPAWN_TAG))
turret.tags = new_tags
# Give a readable label in the World Outliner
base_label = "BP_BaseTurret_perception"
unreal.EditorLevelLibrary.set_actor_label(turret, f"{base_label}_{spawned_count:02d}", True)
existing_spawned_turrets.append(turret)
spawned_count += 1
unreal.log(f"✅ Spawned turret at {loc}")
else:
unreal.log_warning(f"⚠️ Failed to spawn turret at marker: {marker.get_name()}")
unreal.log(f"🎯 Done. Turrets spawned: {spawned_count}")
# MinionSpawn_Build_Robust.py
import unreal
# ================== CONFIG ==================
SOURCE_MODE = "LANES" # "LANES" or "MARKERS"
LANE_BP_PATH = "/Game/Blueprints/BP_Lane.BP_Lane"
LANE_MARKER_PATH = "/Game/PlaceHolders/BP_LaneBuilder.BP_LaneBuilder"
MINION_SPAWN_BP_PATH = "/Game/Blueprints/BP_MinionSpawn.BP_MinionSpawn"
SPAWN_TAG = "SpawnedByScript_Spawner"
CYCLE_LANE_ENUM_IF_UNKNOWN = True
# If SOURCE_MODE == "MARKERS"
DELETE_MARKER_AFTER = False
# ============================================
def set_current_level(level_obj):
ok = False
try:
subsys = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
subsys.set_current_level(level_obj)
ok = True
except Exception:
try:
unreal.EditorLevelUtils.set_current_level(level_obj)
ok = True
except Exception as e:
unreal.log_warning(f"⚠️ Failed to set current level: {e}")
return ok
def get_lane_enum_from_lane(lane_actor, default_idx):
try:
val = lane_actor.get_editor_property("LaneNumber")
if isinstance(val, int):
return val
return int(val)
except Exception:
return default_idx
def find_lanes_robust(world, lane_class):
"""
Try by class first; if none found, fall back to scanning all actors and matching:
- class name contains 'BP_Lane'
- OR has a component named 'PathSpline'
Returns list[Actor].
"""
lanes = []
if lane_class:
lanes = unreal.GameplayStatics.get_all_actors_of_class(world, lane_class)
if lanes:
return lanes
unreal.log_warning("⚠️ No lanes found by class. Falling back to name/component scan…")
all_actors = unreal.EditorLevelLibrary.get_all_level_actors()
for a in all_actors:
try:
cls_name = a.get_class().get_name() # e.g., 'BP_Lane_C'
if "BP_Lane" in cls_name:
lanes.append(a)
continue
# component-hint fallback
comps = a.get_components_by_class(unreal.ActorComponent)
for c in comps:
# PathSpline is typically a USplineComponent named 'PathSpline'
n = c.get_name()
if n == "PathSpline":
lanes.append(a)
break
except Exception:
pass
return lanes
# Load assets
lane_class = unreal.EditorAssetLibrary.load_blueprint_class(LANE_BP_PATH)
marker_class = unreal.EditorAssetLibrary.load_blueprint_class(LANE_MARKER_PATH)
spawner_class = unreal.EditorAssetLibrary.load_blueprint_class(MINION_SPAWN_BP_PATH)
if not spawner_class:
unreal.log_error(f"❌ Could not load spawner class: {MINION_SPAWN_BP_PATH}")
if SOURCE_MODE == "LANES" and not lane_class:
unreal.log_warning(f"⚠️ Could not load lane class: {LANE_BP_PATH} — will try robust fallback scan.")
if SOURCE_MODE == "MARKERS" and not marker_class:
unreal.log_error(f"❌ Could not load marker class: {LANE_MARKER_PATH}")
editor = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
world = editor.get_editor_world()
if SOURCE_MODE == "LANES":
sources = find_lanes_robust(world, lane_class)
else:
sources = unreal.GameplayStatics.get_all_actors_of_class(world, marker_class) if marker_class else []
if not sources:
unreal.log_warning(f"⚠️ No source actors found for mode '{SOURCE_MODE}'. Ensure lanes/markers are loaded.")
else:
# Deterministic order
sources = sorted(sources, key=lambda a: a.get_full_name())
made = 0
for idx, src in enumerate(sources):
loc = src.get_actor_location()
rot = src.get_actor_rotation()
set_current_level(src.get_level())
spawner = unreal.EditorLevelLibrary.spawn_actor_from_class(spawner_class, loc, rot)
if not spawner:
unreal.log_warning(f"⚠️ Failed to spawn BP_MinionSpawn at {src.get_name()}")
continue
unreal.EditorLevelLibrary.set_actor_label(spawner, f"BP_MinionSpawn_{idx:02d}", True)
spawner.tags = list(spawner.tags) + [unreal.Name(SPAWN_TAG)]
if SOURCE_MODE == "LANES":
lane = src
# Bind lane_target
try:
spawner.set_editor_property("lane_target", lane)
except Exception as e:
unreal.log_warning(f"⚠️ Could not set 'lane_target' on {spawner.get_name()}: {e}")
# Decide lane_selectors
fallback = (idx % 3) if CYCLE_LANE_ENUM_IF_UNKNOWN else 0
lane_enum_value = get_lane_enum_from_lane(lane, fallback)
try:
spawner.set_editor_property("lane_selectors", lane_enum_value)
except Exception as e:
unreal.log_warning(f"⚠️ Could not set 'lane_selectors' on {spawner.get_name()}: {e}")
else: # MARKERS
# Find nearest lane and bind (optional but recommended)
try:
lanes_for_bind = find_lanes_robust(world, lane_class)
nearest = None
best_d2 = 1e18
for l in lanes_for_bind:
d2 = (l.get_actor_location() - loc).size_squared()
if d2 < best_d2:
best_d2, nearest = d2, l
if nearest:
spawner.set_editor_property("lane_target", nearest)
fallback = (idx % 3) if CYCLE_LANE_ENUM_IF_UNKNOWN else 0
lane_enum_value = get_lane_enum_from_lane(nearest, fallback)
spawner.set_editor_property("lane_selectors", lane_enum_value)
except Exception as e:
unreal.log_warning(f"⚠️ Could not auto-bind nearest lane/enum: {e}")
if DELETE_MARKER_AFTER:
try:
unreal.EditorLevelLibrary.destroy_actor(src)
unreal.log(f"🧹 Deleted marker {src.get_name()}")
except Exception as e:
unreal.log_warning(f"⚠️ Failed to delete marker {src.get_name()}: {e}")
lvl = src.get_level().get_path_name() if src.get_level() else "UnknownLevel"
unreal.log(f"✅ Spawned MinionSpawner at {loc} in [{lvl}] (mode={SOURCE_MODE})")
made += 1
unreal.log(f"🎯 Done. Minion Spawners spawned: {made} (mode={SOURCE_MODE})")
# MinionSpawn_Build_Robust.py
import unreal
# ================== CONFIG ==================
SOURCE_MODE = "LANES" # "LANES" or "MARKERS"
LANE_BP_PATH = "/Game/Blueprints/BP_Lane.BP_Lane"
LANE_MARKER_PATH = "/Game/PlaceHolders/BP_LaneBuilder.BP_LaneBuilder"
MINION_SPAWN_BP_PATH = "/Game/Blueprints/BP_MinionSpawn.BP_MinionSpawn"
SPAWN_TAG = "SpawnedByScript_Spawner"
CYCLE_LANE_ENUM_IF_UNKNOWN = True
# If SOURCE_MODE == "MARKERS"
DELETE_MARKER_AFTER = False
# ============================================
def set_current_level(level_obj):
ok = False
try:
subsys = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
subsys.set_current_level(level_obj)
ok = True
except Exception:
try:
unreal.EditorLevelUtils.set_current_level(level_obj)
ok = True
except Exception as e:
unreal.log_warning(f"⚠️ Failed to set current level: {e}")
return ok
def get_lane_enum_from_lane(lane_actor, default_idx):
try:
val = lane_actor.get_editor_property("LaneNumber")
if isinstance(val, int):
return val
return int(val)
except Exception:
return default_idx
def find_lanes_robust(world, lane_class):
"""
Try by class first; if none found, fall back to scanning all actors and matching:
- class name contains 'BP_Lane'
- OR has a component named 'PathSpline'
Returns list[Actor].
"""
lanes = []
if lane_class:
lanes = unreal.GameplayStatics.get_all_actors_of_class(world, lane_class)
if lanes:
return lanes
unreal.log_warning("⚠️ No lanes found by class. Falling back to name/component scan…")
all_actors = unreal.EditorLevelLibrary.get_all_level_actors()
for a in all_actors:
try:
cls_name = a.get_class().get_name() # e.g., 'BP_Lane_C'
if "BP_Lane" in cls_name:
lanes.append(a)
continue
# component-hint fallback
comps = a.get_components_by_class(unreal.ActorComponent)
for c in comps:
# PathSpline is typically a USplineComponent named 'PathSpline'
n = c.get_name()
if n == "PathSpline":
lanes.append(a)
break
except Exception:
pass
return lanes
# Load assets
lane_class = unreal.EditorAssetLibrary.load_blueprint_class(LANE_BP_PATH)
marker_class = unreal.EditorAssetLibrary.load_blueprint_class(LANE_MARKER_PATH)
spawner_class = unreal.EditorAssetLibrary.load_blueprint_class(MINION_SPAWN_BP_PATH)
if not spawner_class:
unreal.log_error(f"❌ Could not load spawner class: {MINION_SPAWN_BP_PATH}")
if SOURCE_MODE == "LANES" and not lane_class:
unreal.log_warning(f"⚠️ Could not load lane class: {LANE_BP_PATH} — will try robust fallback scan.")
if SOURCE_MODE == "MARKERS" and not marker_class:
unreal.log_error(f"❌ Could not load marker class: {LANE_MARKER_PATH}")
editor = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
world = editor.get_editor_world()
if SOURCE_MODE == "LANES":
sources = find_lanes_robust(world, lane_class)
else:
sources = unreal.GameplayStatics.get_all_actors_of_class(world, marker_class) if marker_class else []
if not sources:
unreal.log_warning(f"⚠️ No source actors found for mode '{SOURCE_MODE}'. Ensure lanes/markers are loaded.")
else:
# Deterministic order
sources = sorted(sources, key=lambda a: a.get_full_name())
made = 0
for idx, src in enumerate(sources):
loc = src.get_actor_location()
rot = src.get_actor_rotation()
set_current_level(src.get_level())
spawner = unreal.EditorLevelLibrary.spawn_actor_from_class(spawner_class, loc, rot)
if not spawner:
unreal.log_warning(f"⚠️ Failed to spawn BP_MinionSpawn at {src.get_name()}")
continue
unreal.EditorLevelLibrary.set_actor_label(spawner, f"BP_MinionSpawn_{idx:02d}", True)
spawner.tags = list(spawner.tags) + [unreal.Name(SPAWN_TAG)]
if SOURCE_MODE == "LANES":
lane = src
# Bind lane_target
try:
spawner.set_editor_property("lane_target", lane)
except Exception as e:
unreal.log_warning(f"⚠️ Could not set 'lane_target' on {spawner.get_name()}: {e}")
# Decide lane_selectors
fallback = (idx % 3) if CYCLE_LANE_ENUM_IF_UNKNOWN else 0
lane_enum_value = get_lane_enum_from_lane(lane, fallback)
try:
spawner.set_editor_property("lane_selectors", lane_enum_value)
except Exception as e:
unreal.log_warning(f"⚠️ Could not set 'lane_selectors' on {spawner.get_name()}: {e}")
else: # MARKERS
# Find nearest lane and bind (optional but recommended)
try:
lanes_for_bind = find_lanes_robust(world, lane_class)
nearest = None
best_d2 = 1e18
for l in lanes_for_bind:
d2 = (l.get_actor_location() - loc).size_squared()
if d2 < best_d2:
best_d2, nearest = d2, l
if nearest:
spawner.set_editor_property("lane_target", nearest)
fallback = (idx % 3) if CYCLE_LANE_ENUM_IF_UNKNOWN else 0
lane_enum_value = get_lane_enum_from_lane(nearest, fallback)
spawner.set_editor_property("lane_selectors", lane_enum_value)
except Exception as e:
unreal.log_warning(f"⚠️ Could not auto-bind nearest lane/enum: {e}")
if DELETE_MARKER_AFTER:
try:
unreal.EditorLevelLibrary.destroy_actor(src)
unreal.log(f"🧹 Deleted marker {src.get_name()}")
except Exception as e:
unreal.log_warning(f"⚠️ Failed to delete marker {src.get_name()}: {e}")
lvl = src.get_level().get_path_name() if src.get_level() else "UnknownLevel"
unreal.log(f"✅ Spawned MinionSpawner at {loc} in [{lvl}] (mode={SOURCE_MODE})")
made += 1
unreal.log(f"🎯 Done. Minion Spawners spawned: {made} (mode={SOURCE_MODE})")
# PlayerStart_Build.py
import unreal
# ================== CONFIG ==================
# Placeholder (marker) that indicates where to spawn the new player start
PLAYER_MARKER_PATH = "/Game/PlaceHolders/BP_PlayerStartMarker.BP_PlayerStartMarker"
# The actual player start Blueprint to spawn
PLAYER_START_BP_PATH = "/Game/Blueprints/PlayerBlueprints/BP_NewPlayerStartMarker.BP_NewPlayerStartMarker"
SPAWN_TAG = "SpawnedByScript_PlayerStart"
# Delete the old marker once we've spawned the new one?
DELETE_MARKER_AFTER = True
# ============================================
# --- Load the Blueprint classes ---
marker_class = unreal.EditorAssetLibrary.load_blueprint_class(PLAYER_MARKER_PATH)
player_start_class = unreal.EditorAssetLibrary.load_blueprint_class(PLAYER_START_BP_PATH)
if not marker_class:
unreal.log_error(f"❌ Could not load marker class: {PLAYER_MARKER_PATH}")
if not player_start_class:
unreal.log_error(f"❌ Could not load player start class: {PLAYER_START_BP_PATH}")
def set_current_level(level_obj):
"""Ensure we spawn in the same level as the source actor."""
try:
subsys = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
subsys.set_current_level(level_obj)
except Exception:
try:
unreal.EditorLevelUtils.set_current_level(level_obj)
except Exception as e:
unreal.log_warning(f"⚠️ Failed to set current level: {e}")
if marker_class and player_start_class:
editor = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
world = editor.get_editor_world()
# Find all placeholder actors in the loaded level(s)
markers = unreal.GameplayStatics.get_all_actors_of_class(world, marker_class)
if not markers:
unreal.log_warning("⚠️ No BP_PlayerStartMarker actors found in the current world.")
else:
markers = sorted(markers, key=lambda a: a.get_full_name())
spawned_count = 0
for idx, marker in enumerate(markers):
loc = marker.get_actor_location()
rot = marker.get_actor_rotation()
set_current_level(marker.get_level())
# Spawn the new Player Start
player_start = unreal.EditorLevelLibrary.spawn_actor_from_class(player_start_class, loc, rot)
if not player_start:
unreal.log_warning(f"⚠️ Failed to spawn PlayerStart at {marker.get_name()}")
continue
# Label + tag
unreal.EditorLevelLibrary.set_actor_label(player_start, f"BP_NewPlayerStart_{idx:02d}", True)
player_start.tags = list(player_start.tags) + [unreal.Name(SPAWN_TAG)]
# Delete the marker afterward (optional)
if DELETE_MARKER_AFTER:
try:
unreal.EditorLevelLibrary.destroy_actor(marker)
unreal.log(f"🧹 Deleted placeholder {marker.get_name()}")
except Exception as e:
unreal.log_warning(f"⚠️ Failed to delete placeholder {marker.get_name()}: {e}")
unreal.log(f"✅ Spawned Player Start at {loc}")
spawned_count += 1
unreal.log(f"🎯 Done. Spawned {spawned_count} PlayerStart actors.")