ShippedMedium

Clean up Seminole UE 5.8 scaffold; add runtime-spawned test environment, placeholder character, launch scripts and Getting Started docs

Proposed by Jonathan Miller 2 hours agoFunding opened 2 hours agoFunded 2 hours agoShipped 2 hours ago
Specification

Motivation

Ticket #21 produced ARCHITECTURE.md, the ADRs and a C++ scaffold. This ticket turns that scaffold into a clean UE 5.8 baseline. A Windows developer should be able to clone, open, build, run and package it without manual repair. It adds only the minimum needed to prove the project boots and adds no gameplay systems.

Environment constraint

The build agent has no Unreal Engine and no Windows, so it cannot compile, launch or package. All validation is static. Runtime verification is handed off to a manual checklist (docs/VALIDATION.md). The agent must not claim that compilation, launch or packaging succeeded.

Decisions (from proposer answers)

  • Engine: UE 5.8 is the definitive target. Align EngineAssociation, docs and config to 5.8 regardless of what existing files say.
  • Map: use both approaches.
    • (a) The game boots into an engine-provided map, and C++ spawns a floor, lighting and a PlayerStart at runtime when they are missing.
    • (b) An optional editor Python script generates /Game/Maps/L_TestMap locally.
    • The runtime fallback must work even if the script was never run.
  • Input: a C++ ACharacter subclass using legacy axis/action mappings in Config/DefaultInput.ini, bound in SetupPlayerInputComponent. No Enhanced Input in this ticket.

Scope

1. Repository cleanup

  • Audit the repo for placeholder, duplicate, stale or inconsistent scaffold files. Fix or remove only files that are clearly generated, redundant or broken. List each removal and its reason in the PR description.
  • Preserve ARCHITECTURE.md, the ADRs and other intentional docs. Update version references in them to 5.8 only where they contradict it.
  • Use consistent names everywhere: Seminole, SeminoleEditor, Source/Seminole.
  • .gitignore covers:
    • Binaries/, Intermediate/, Saved/, DerivedDataCache/, .vs/, .idea/
    • *.sln
    • the packaged output folder (assumption: Packaged/, documented in README)
  • .gitattributes routes these through Git LFS: *.uasset, *.umap, *.fbx, *.png, *.tga, *.jpg, *.wav, *.mp3, *.ogg, *.exr, *.psd.

2. Project validity

  • Seminole.uproject has "EngineAssociation": "5.8" and exactly one module: Seminole, Type Runtime, LoadingPhase Default.
  • Plugins:
    • Do not enable Steam, OnlineSubsystem*, StateTree, PCG, CommonUI or MetaSounds.
    • Explicitly disable EnhancedInput only if needed for legacy input to work. Assumption: leave it at the engine default and switch input classes via config instead (see section 4).
  • Seminole.Target.cs uses TargetType.Game and SeminoleEditor.Target.cs uses TargetType.Editor.
    • Both use BuildSettingsVersion.Latest and IncludeOrderVersion = EngineIncludeOrderVersion.Latest.
    • Both have ExtraModuleNames.Add("Seminole").
  • Seminole.Build.cs depends only on Core, CoreUObject, Engine and InputCore.
  • Seminole.cpp/Seminole.h contain IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, Seminole, "Seminole").

3. Minimal bootable shell (C++ only, no binary assets authored)

  • ASeminoleGameMode
    • Sets DefaultPawnClass = ASeminolePlaceholderCharacter::StaticClass().
    • At StartPlay/InitGame (before player spawn), ensures the world contains a floor, a directional light, a sky light and a PlayerStart. It spawns any missing piece itself or through a small ASeminoleTestEnvironment actor.
    • The floor is a StaticMeshActor/component using /Engine/BasicShapes/Cube or Plane, scaled to roughly 100m x 100m, with collision enabled.
    • Lights are spawned as movable.
    • The PlayerStart sits above the floor. Alternatively, override ChoosePlayerStart/FindPlayerStart to return the spawned one.
    • Nothing is spawned when the corresponding actor already exists. This keeps the generated L_TestMap from receiving duplicates.
  • ASeminolePlaceholderCharacter (an ACharacter) has:
    • the default capsule
    • a visible UStaticMeshComponent using /Engine/BasicShapes/Cylinder, loaded via ConstructorHelpers::FObjectFinder
    • a USpringArmComponent
    • a UCameraComponent
  • Controls:
    • MoveForward/MoveRight axes (W/S and D/A, scale ±1)
    • Turn/LookUp axes (MouseX, MouseY with -1 scale)
    • Jump action (SpaceBar)
    • All bound in SetupPlayerInputComponent via BindAxis/BindAction.
  • Scripts/Editor/create_test_map.py (optional to run):
    • Uses unreal editor Python APIs to create and save /Game/Maps/L_TestMap with a floor, a directional light, a sky light and a PlayerStart.
    • Header comments explain how to run it and how to repoint config at the map afterwards.
    • Requires the Python Editor Script Plugin, which is documented but not enabled by default. Assumption: the agent may instead enable PythonScriptPlugin in the uproject as editor-only if needed; note this choice in the PR.

4. Config

  • DefaultEngine.ini:
    • GameDefaultMap and EditorStartupMap point to an engine-provided map. Assumption: /Engine/Maps/Entry, or another map the agent can justify as shipped with the engine.
    • GlobalDefaultGameMode=/Script/Seminole.SeminoleGameMode.
  • DefaultInput.ini:
    • Contains the axis/action mappings.
    • Sets DefaultPlayerInputClass=/Script/Engine.PlayerInput and DefaultInputComponentClass=/Script/Engine.InputComponent, so legacy bindings work under UE5's Enhanced Input defaults.
  • DefaultGame.ini has a project name/ID section.
    • Packaging settings must ensure the engine map is cooked. Assumption: add it to +MapsToCook if required.
  • No config key references a nonexistent asset, map or class.

5. Scripts

  • scripts/open-project.bat:
    • If UE_ROOT is set and %UE_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe exists, it launches that with the .uproject.
    • Otherwise it runs start "" Seminole.uproject (file association).
    • It resolves the repo root relative to the script location.
    • It prints a clear error if neither works.
  • scripts/run-latest-build.bat launches Packaged\Windows\Seminole.exe. If it is missing, it prints a clear error and exits with a non-zero code.
  • Neither script contains hardcoded absolute engine install paths.

6. Documentation

  • README Getting Started section:
    • UE 5.8 requirement
    • git lfs install
    • clone
    • Generate Visual Studio project files
    • open the .uproject
    • build Development Editor | Win64 in VS/Rider
    • Play/Standalone
    • Packaging (Platforms > Windows > Development, output to Packaged/)
    • launching Seminole.exe (or run-latest-build.bat)
    • optional create_test_map.py
    • Troubleshooting: missing modules/rebuild prompt, regenerating project files, LFS pointer files
  • docs/VALIDATION.md is a step-by-step manual checklist:
    • Build the editor target.
    • Open the editor and confirm the startup map loads with a floor and lighting.
    • In PIE, confirm WASD movement, mouse look and jump.
    • Package Win64 Development.
    • Run the exe and confirm the same scene loads.
    • (Optional) Run the Python script and boot L_TestMap.
    • It must state explicitly that none of these steps were executed by the implementer.

Acceptance criteria (statically verifiable)

  1. Seminole.uproject parses as JSON. It has one Runtime module Seminole, EngineAssociation "5.8", and none of the excluded plugins enabled.
  2. Target.cs and Build.cs names match the files on disk. Every #include in Source/ resolves to a repo file or an engine module listed in Build.cs.
  3. ASeminoleGameMode and ASeminolePlaceholderCharacter exist. All asset paths referenced in C++ are /Engine/BasicShapes/* built-ins.
  4. The GameMode's spawn logic covers the floor, directional light, sky light and PlayerStart, each guarded by an existence check.
  5. DefaultEngine.ini sets GameDefaultMap, EditorStartupMap and GlobalDefaultGameMode, and each resolves to an engine map or a C++ class in the repo.
  6. DefaultInput.ini defines MoveForward, MoveRight, Turn, LookUp and Jump, plus the legacy input class overrides. The character binds the same names.
  7. create_test_map.py exists and creates /Game/Maps/L_TestMap with the four elements. No .umap/.uasset is committed by the agent.
  8. .gitignore and .gitattributes match section 1, and git ls-files shows no tracked generated folders.
  9. Both .bat scripts exist with no absolute engine paths and with explicit error messages. The run script exits non-zero when the exe is missing.
  10. The README Getting Started section and docs/VALIDATION.md cover every listed item.
  11. The PR description states that compile, launch and package were not run, and lists the files removed during cleanup.
  12. The PR adds no gameplay systems, online/Steam config or art assets.

Out of scope

  • Zombie AI, noise attraction
  • Inventory, weapons, combat, health
  • Canoe and settlement systems
  • Multiplayer/Steam, save games
  • Enhanced Input
  • Final UI, controller, map, art, audio, animation, VFX
  • CI/CD, dedicated servers, browser builds
Build #21
SUCCEEDED

Model claude-fable-5-1 · ceiling $9 · started 2 hours ago · finished 2 hours ago

48agent turns
4.2Mtokens
$7.41spent at list price

Builder summary
## Feature #22: Clean UE 5.8 baseline — runtime-spawned test environment, placeholder character, launch scripts, Getting Started docs > **Not compiled, launched or packaged.** This sandbox has no Unreal Engine and no Windows. Every check was static (JSON parse, include resolution against Build.cs modules, config/class name cross-checks, Python `ast` parse, grep for absolute paths). Runtime verification is handed off to `docs/VALIDATION.md`, which states this explicitly. Please run that checklist before merging or soon after. ### What changed **C++ (`Source/Seminole/`, module `Seminole`, API macro `SEMINOLE_API`)** - `SeminoleGameMode.h/.cpp` — `ASeminoleGameMode : AGameModeBase`. Sets `DefaultPawnClass = ASeminolePlaceholderCharacter`. In `InitGame` (after level actors register, before the first player logs in) it finds-or-spawns one `ASeminoleTestEnvironment` and calls `EnsureSceneBasics()`. - `SeminoleTestEnvironment.h/.cpp` — `ASeminoleTestEnvironment : AActor`. Four guarded spawns: floor (`/Engine/BasicShapes/Cube`, scale 100×100×1 → 100 m × 100 m × 1 m, top at Z=0, `BlockAll`, QueryAndPhysics, Static mobility set via deferred spawn), movable `ADirectionalLight`, movable `ASkyLight`, `APlayerStart` at Z=120. Existence checks: floor = any `AStaticMeshActor` tagged `SeminoleFloor` *or* a downward `ECC_WorldStatic` line trace through the origin hits something; lights = any actor with a `UDirectionalLightComponent` / `USkyLightComponent`; PlayerStart = `TActorIterator<APlayerStart>`. The Cube is resolved with `ConstructorHelpers::FObjectFinder` so the CDO hard-references it (gets cooked). Each spawn logs to a new `LogSeminole` category (declared in `Seminole.h`, defined in `Seminole.cpp`) so validators can see exactly what was spawned. - `SeminolePlaceholderCharacter.h/.cpp` — `ACharacter` with default capsule (34/88), a no-collision `UStaticMeshComponent` using `/Engine/BasicShapes/Cylinder` (FObjectFinder, scaled 0.68×0.68×1.76 to fit the capsule), `USpringArmComponent` (400 cm, pawn control rotation) and `UCameraComponent`. `SetupPlayerInputComponent` binds `MoveForward`/`MoveRight` (camera-yaw relative), `Turn`/`LookUp` (`APawn::AddControllerYaw/PitchInput`) and `Jump` pressed/released. - `Seminole.Build.cs` — now depends only on Core, CoreUObject, Engine, InputCore (dropped `EnhancedInput`). - Target.cs files unchanged (already `TargetType.Game`/`Editor`, `BuildSettingsVersion.Latest`, `IncludeOrderVersion Latest`, `ExtraModuleNames.Add("Seminole")`). `Seminole.uproject` unchanged: `EngineAssociation` "5.8", one Runtime/Default module `Seminole`, empty `Plugins` list (no Steam/OSS/StateTree/PCG/CommonUI/MetaSounds; `PythonScriptPlugin` **not** enabled — documented instead). **Config** - `DefaultEngine.ini` — `GameDefaultMap` and `EditorStartupMap` = `/Engine/Maps/Entry.Entry` (the engine's own default map, present in every UE4/UE5 install; empty, so the runtime fallback is exercised on every boot), `GlobalDefaultGameMode=/Script/Seminole.SeminoleGameMode`. - `DefaultInput.ini` — `DefaultPlayerInputClass=/Script/Engine.PlayerInput`, `DefaultInputComponentClass=/Script/Engine.InputComponent`, axis mappings W/S ±1 `MoveForward`, D/A ±1 `MoveRight`, MouseX `Turn`, MouseY −1 `LookUp`, action `Jump`=SpaceBar. - `DefaultGame.ini` — kept the project name/ID section; added `[ProjectPackagingSettings]` with `Build=IfProjectHasCode`, `BuildConfiguration=PPBC_Development`, `+MapsToCook=(FilePath="/Engine/Maps/Entry")` (belt-and-braces; the cooker also includes GameDefaultMap automatically). **Scripts (`scripts/`)** - `open-project.bat` — resolves repo root from `%~dp0`, uses `%UE_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe` if set and present, else checks `assoc .uproject` and uses `start "" Seminole.uproject`; clear errors and `exit /b 1` when neither works. No absolute engine paths. CRLF enforced via `.gitattributes`. - `run-latest-build.bat` — launches `Packaged\Windows\Seminole.exe`, passes through args; prints how to packa
View pull request ↗Download patchnabled/feature-22-job-21

Agent log
- `SeminolePlaceholderCharacter.h/.cpp` — `ACharacter` with default capsule (34/88), a no-collision `UStaticMeshComponent` using `/Engine/BasicShapes/Cylinder` (FObjectFinder, scaled 0.68×0.68×1.76 to fit the capsule), `USpringArmComponent` (400 cm, pawn control rotation) and `UCameraComponent`. `SetupPlayerInputComponent` binds `MoveForward`/`MoveRight` (camera-yaw relative), `Turn`/`LookUp` (`APawn::AddControllerYaw/PitchInput`) and `Jump` pressed/released.
- `Seminole.Build.cs` — now depends only on Core, CoreUObject, Engine, InputCore (dropped `EnhancedInput`).
- Target.cs files unchanged (already `TargetType.Game`/`Editor`, `BuildSettingsVersion.Latest`, `IncludeOrderVersion Latest`, `ExtraModuleNames.Add("Seminole")`). `Seminole.uproject` unchanged: `EngineAssociation` "5.8", one Runtime/Default module `Seminole`, empty `Plugins` list (no Steam/OSS/StateTree/PCG/CommonUI/MetaSounds; `PythonScriptPlugin` **not** enabled — documented instead).

**Config**
- `DefaultEngine.ini` — `GameDefaultMap` and `EditorStartupMap` = `/Engine/Maps/Entry.Entry` (the engine's own default map, present in every UE4/UE5 install; empty, so the runtime fallback is exercised on every boot), `GlobalDefaultGameMode=/Script/Seminole.SeminoleGameMode`.
- `DefaultInput.ini` — `DefaultPlayerInputClass=/Script/Engine.PlayerInput`, `DefaultInputComponentClass=/Script/Engine.InputComponent`, axis mappings W/S ±1 `MoveForward`, D/A ±1 `MoveRight`, MouseX `Turn`, MouseY −1 `LookUp`, action `Jump`=SpaceBar.
- `DefaultGame.ini` — kept the project name/ID section; added `[ProjectPackagingSettings]` with `Build=IfProjectHasCode`, `BuildConfiguration=PPBC_Development`, `+MapsToCook=(FilePath="/Engine/Maps/Entry")` (belt-and-braces; the cooker also includes GameDefaultMap automatically).

**Scripts (`scripts/`)**
- `open-project.bat` — resolves repo root from `%~dp0`, uses `%UE_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe` if set and present, else checks `assoc .uproject` and uses `start "" Seminole.uproject`; clear errors and `exit /b 1` when neither works. No absolute engine paths. CRLF enforced via `.gitattributes`.
- `run-latest-build.bat` — launches `Packaged\Windows\Seminole.exe`, passes through args; prints how to package and exits 1 if missing.
- `editor/create_test_map.py` — idempotent: creates or opens `/Game/Maps/L_TestMap`, adds whichever of floor (tagged `SeminoleFloor`, same transform as C++), movable directional light, movable sky light, PlayerStart are missing, saves. Header documents enabling the Python Editor Script Plugin, three ways to run it, and how to repoint `GameDefaultMap`/`EditorStartupMap`/`MapsToCook`.

**Repo hygiene**
- `.gitignore` adds `Packaged/` (assumed packaged output folder; documented in README). Already covered: Binaries/, Intermediate/, Saved/, DerivedDataCache/, .vs/, .idea/, *.sln.
- `.gitattributes` adds LFS for `*.jpg`, `*.exr`, `*.mp3`, `*.ogg` (uasset/umap/fbx/png/tga/wav/psd were already there) plus `*.bat text eol=crlf`.
- No `.umap`/`.uasset` committed; `git ls-files` shows no generated folders.

**Docs**
- `README.md` — new **Getting Started** (UE 5.8 requirement, `git lfs install`, clone, generate VS project files, build Development Editor | Win64, open, Play/Standalone, Package → `Packaged/`, run exe / `run-latest-build.bat`, optional `create_test_map.py`) and **Troubleshooting** (missing-modules/rebuild prompt, regenerating project files, LFS pointer files, wrong engine version, PIE spawn-at-camera pitfall). Project layout updated.
- `docs/VALIDATION.md` — step-by-step manual checklist for build, editor/startup map, PIE (WASD, mouse look, jump, duplicate check), package Win64 Development, run exe (incl. the script's failure path), optional L_TestMap; states none of it was executed by the implementer.
- `docs/ARCHITECTURE.md` — only consistency touches: LFS pattern list, `.gitignore`/`Packaged/` note, one sentence that the placeholder character uses legacy input until the Characters ticket. ADRs untouched (already 5.8).
- `Source/Seminole/Characters/README.md` — one sentence pointing out the placeholder character is bootstrap scaffolding.

### Files removed during cleanup
- `Plugins/.gitkeep` — empty placeholder directory; UE creates `Plugins/` when a plugin is added and the project enables none. README layout updated accordingly.

Everything else in the scaffold was intentional (ARCHITECTURE.md, ADRs, per-system README folders, `Content/*/.gitkeep` layout, the Babylon.js browser prototype documented under "Browser strategy", the nabled Pages workflow) and was preserved. Stale content was rewritten in place rather than removed: the Enhanced Input comments/classes in `DefaultInput.ini`, the "no maps yet" comment in `DefaultEngine.ini`, and the `EnhancedInput` Build.cs dependency.

### Assumptions / deviations to review
- **Script folder case:** the spec names `Scripts/Editor/create_test_map.py` and `scripts/*.bat`; two folders differing only by case would break Windows checkouts, so everything is under lowercase `scripts/` (`scripts/editor/create_test_map.py`).
- **Entry map in the editor is empty until Play** — the floor/lights only exist at runtime. VALIDATION.md phrases the editor check accordingly.
- **PIE "Spawn player at: Current Camera Location"** (engine default) may put the pawn outside the runtime floor in the empty map; documented in README troubleshooting and VALIDATION.md rather than overriding `ChoosePlayerStart`, so editor UX stays standard.
- Existing `docs/screenshots/*.jpg` remain ordinary Git blobs (same situation as `before.png` from #21; ADR 0008 already documents this); only new `.jpg` files go through LFS.
- Legacy input (`BindAxis`/`UPlayerInput`) is deprecated-in-UI but still shipped in 5.x; if 5.8 emits compiler deprecation warnings for it, they are expected — this is a stopgap until the Enhanced Input character ticket.

### Things a maintainer with UE 5.8 should look at closely
1. That `/Engine/Maps/Entry` still exists in 5.8 and has no WorldSettings game-mode override (otherwise `GlobalDefaultGameMode` is ignored).
2. The deferred-spawn + `SetStaticMesh` on the Static-mobility floor compiles and produces a collidable floor in both PIE and the packaged build (if the floor is missing in the packaged build, `/Engine/BasicShapes` was not cooked — add it to *Additional Asset Directories to Cook*).
3. `scripts/open-project.bat` fallback behaviour on a machine with and without the `.uproject` association.
4. The Python script's `set_editor_property("mobility", …)` / `set_collision_profile_name` calls against the 5.8 Python API.

2026-10-05T08:53:57Z  [claude] The work is complete and pushed to `nabled/feature-22-job-21`. 

**Summary of what was delivered:**

- **Bootable C++ shell** — `ASeminoleGameMode` spawns an `ASeminoleTestEnvironment` in `InitGame`, which adds a 100 m floor, movable directional + sky lights and a PlayerStart only where missing (tag/trace/component/class existence checks). `ASeminolePlaceholderCharacter` provides capsule + cylinder body + spring-arm camera and legacy WASD / mouse-look / jump bindings.
- **Config** aligned to UE 5.8: boots into `/Engine/Maps/Entry`, `GlobalDefaultGameMode` set, legacy input classes and mappings, `MapsToCook` for the engine map.
- **Scripts**: `open-project.bat`, `run-latest-build.bat` (both CRLF, no hardcoded engine paths, explicit errors / non-zero exit), and an idempotent `create_test_map.py`.
- **Hygiene**: `Packaged/` ignored, extra LFS patterns, `EnhancedInput` dependency dropped, empty `Plugins/.gitkeep` removed.
- **Docs**: README Getting Started + Troubleshooting, `docs/VALIDATION.md` manual checklist.

One important caveat, which is also in the PR description: nothing was compiled, launched or packaged — the sandbox has no Unreal Engine or Windows, so all verification was static. The validation checklist is the hand-off for a developer with UE 5.8.
2026-10-05T08:54:01Z  Changed files:
modified  .gitattributes  +7 -0
modified  .gitignore  +3 -0
modified  Config/DefaultEngine.ini  +7 -2
modified  Config/DefaultGame.ini  +7 -0
modified  Config/DefaultInput.ini  +13 -3
removed  Plugins/.gitkeep  +0 -0
modified  README.md  +116 -12
modified  Source/Seminole/Characters/README.md  +1 -0
modified  Source/Seminole/Seminole.Build.cs  +1 -2
modified  Source/Seminole/Seminole.cpp  +2 -0
modified  Source/Seminole/Seminole.h  +3 -0
added  Source/Seminole/SeminoleGameMode.cpp  +49 -0
added  Source/Seminole/SeminoleGameMode.h  +26 -0
added  Source/Seminole/SeminolePlaceholderCharacter.cpp  +93 -0
added  Source/Seminole/SeminolePlaceholderCharacter.h  +44 -0
added  Source/Seminole/SeminoleTestEnvironment.cpp  +216 -0
added  Source/Seminole/SeminoleTestEnvironment.h  +55 -0
modified  docs/ARCHITECTURE.md  +9 -4
added  docs/VALIDATION.md  +106 -0
added  scripts/editor/create_test_map.py  +151 -0
added  scripts/open-project.bat  +50 -0
added  scripts/run-latest-build.bat  +20 -0
2026-10-05T08:54:02Z  Opened pull request https://github.com/nabledhq/seminole/pull/9
2026-10-05T08:54:02Z  Finished: success=true turns=48 tokens(in/out)=4074442/87217 list cost=$7.41

Show patch
diff --git a/.gitattributes b/.gitattributes
index a6ff67c..d8a4cbd 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -11,3 +11,10 @@
 *.spp filter=lfs diff=lfs merge=lfs -text
 *.sbs filter=lfs diff=lfs merge=lfs -text
 *.sbsar filter=lfs diff=lfs merge=lfs -text
+*.jpg filter=lfs diff=lfs merge=lfs -text
+*.exr filter=lfs diff=lfs merge=lfs -text
+*.mp3 filter=lfs diff=lfs merge=lfs -text
+*.ogg filter=lfs diff=lfs merge=lfs -text
+
+# Windows batch files must keep CRLF line endings to run reliably.
+*.bat text eol=crlf
diff --git a/.gitignore b/.gitignore
index ecab79a..d0f6273 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,6 @@ Build/Receipts/
 *.xcworkspace
 *.code-workspace
 *.generated.h
+
+# Packaged game output (see README: Platforms > Windows > Development, output to Packaged/)
+Packaged/
diff --git a/Config/DefaultEngine.ini b/Config/DefaultEngine.ini
index 03aa992..cdb7b43 100644
--- a/Config/DefaultEngine.ini
+++ b/Config/DefaultEngine.ini
@@ -1,6 +1,11 @@
 [/Script/EngineSettings.GameMapsSettings]
-; No startup, default or server maps are set: Content/Maps is empty until the first map ticket lands.
-; GlobalDefaultGameMode is left at the engine default for the same reason.
+; The project boots into an empty map that ships with the engine. ASeminoleGameMode spawns a
+; floor, a directional light, a sky light and a PlayerStart at runtime when the map has none,
+; so no authored map is needed yet. To use the locally generated test map instead, run
+; scripts/editor/create_test_map.py and point both entries at /Game/Maps/L_TestMap.L_TestMap.
+GameDefaultMap=/Engine/Maps/Entry.Entry
+EditorStartupMap=/Engine/Maps/Entry.Entry
+GlobalDefaultGameMode=/Script/Seminole.SeminoleGameMode
 
 [/Script/HardwareTargeting.HardwareTargetingSettings]
 TargetedHardwareClass=Desktop
diff --git a/Config/DefaultGame.ini b/Config/DefaultGame.ini
index 73d3654..c0c002f 100644
--- a/Config/DefaultGame.ini
+++ b/Config/DefaultGame.ini
@@ -5,3 +5,10 @@ CompanyName=Seminole contributors
 Description=A co-op survival game set in the Florida Everglades around 1900.
 ProjectVersion=0.1.0
 CopyrightNotice=Copyright Seminole contributors. MIT licence.
+
+[/Script/UnrealEd.ProjectPackagingSettings]
+Build=IfProjectHasCode
+BuildConfiguration=PPBC_Development
+; The startup map lives in engine content, so it is listed explicitly to guarantee it is cooked.
+; Maps added under Content/Maps later must be added here too (or be the GameDefaultMap).
++MapsToCook=(FilePath="/Engine/Maps/Entry")
diff --git a/Config/DefaultInput.ini b/Config/DefaultInput.ini
index 66c1186..3bcda00 100644
--- a/Config/DefaultInput.ini
+++ b/Config/DefaultInput.ini
@@ -1,4 +1,14 @@
 [/Script/Engine.InputSettings]
-DefaultPlayerInputClass=/Script/EnhancedInput.EnhancedPlayerInput
-DefaultInputComponentClass=/Script/EnhancedInput.EnhancedInputComponent
-; Input Actions and Input Mapping Contexts are data assets and land under Content/ with the first character ticket.
+; Legacy axis/action mappings for the bootstrap ASeminolePlaceholderCharacter. UE5 projects
+; default to the Enhanced Input classes; these two lines switch back to the classic ones so the
+; mappings below are processed. Enhanced Input (Input Actions and Mapping Contexts under
+; Content/) arrives with the first real character ticket.
+DefaultPlayerInputClass=/Script/Engine.PlayerInput
+DefaultInputComponentClass=/Script/Engine.InputComponent
++AxisMappings=(AxisName="MoveForward",Scale=1.000000,Key=W)
++AxisMappings=(AxisName="MoveForward",Scale=-1.000000,Key=S)
++AxisMappings=(AxisName="MoveRight",Scale=1.000000,Key=D)
++AxisMappings=(AxisName="MoveRight",Scale=-1.000000,Key=A)
++AxisMappings=(AxisName="Turn",Scale=1.000000,Key=MouseX)
++AxisMappings=(AxisName="LookUp",Scale=-1.000000,Key=MouseY)
++ActionMappings=(ActionName="Jump",bShift=False,bCtrl=False,bAlt=False,bCmd=False,Key=SpaceBar)
diff --git a/Plugins/.gitkeep b/Plugins/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/README.md b/README.md
index 5dd1a83..a3ed19c 100644
--- a/README.md
+++ b/README.md
@@ -4,25 +4,125 @@ Seminole is a 3d game where the user is part of a Seminole tribe in the 1900s
 ## Unreal Engine project
 
 The shipping game is built with **Unreal Engine 5.8** (`Seminole.uproject`,
-C++ module `Seminole` under `Source/`, content under `Content/`). The
+C++ module `Seminole` under `Source/Seminole/`, content under `Content/`). The
 technical foundation is documented in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
-and the decisions behind it in [docs/adr/](docs/adr/README.md). The UE project
-is currently a skeleton: no gameplay, maps or plugins yet.
+and the decisions behind it in [docs/adr/](docs/adr/README.md).
 
-Binary assets go through **Git LFS**. Install it once, *before* cloning or
-pulling, so LFS files are fetched instead of pointer stubs:
+The project is a minimal bootable shell with **no gameplay systems**: it opens
+an empty engine map, `ASeminoleGameMode` spawns a floor, lights and a
+PlayerStart at runtime, and `ASeminolePlaceholderCharacter` (a capsule with a
+cylinder, third-person camera, WASD / mouse look / jump) is the pawn. Nothing
+in it has been compiled, launched or packaged by its author yet; the manual
+checklist in [docs/VALIDATION.md](docs/VALIDATION.md) covers that.
+
+## Getting Started
+
+Windows is the supported development platform.
+
+### Requirements
+
+- **Unreal Engine 5.8**, installed through the Epic Games Launcher (the
+  `.uproject` pins `EngineAssociation` to `5.8`).
+- **Visual Studio 2022** with the *Game development with C++* workload
+  (including the Windows 10/11 SDK and MSVC), or JetBrains **Rider**.
+- **Git** and **Git LFS**.
+
+### 1. Install Git LFS and clone
+
+Binary assets (`.uasset`, `.umap`, textures, meshes, audio) go through Git
+LFS. Install it *before* cloning so LFS files are fetched instead of pointer
+stubs:
 
 ```sh
 git lfs install
 git clone https://github.com/nabledhq/seminole.git
+cd seminole
 ```
 
 If you cloned before installing LFS, run `git lfs install` and then
 `git lfs pull` inside the repository.
 
-To open the project, install Unreal Engine 5.8 with a C++ toolchain,
-right-click `Seminole.uproject` and generate project files, then build the
-`SeminoleEditor` target and open the `.uproject`.
+### 2. Generate Visual Studio project files
+
+Right-click `Seminole.uproject` in Explorer and choose **Generate Visual Studio
+project files**. This creates `Seminole.sln` (ignored by Git). If the menu
+entry is missing, run the Epic Games Launcher once so it registers the
+`.uproject` file type, or use the Unreal Version Selector from the engine
+install (`Engine\Binaries\Win64\UnrealVersionSelector.exe /projectfiles
+<path>\Seminole.uproject`).
+
+### 3. Build the editor
+
+Open `Seminole.sln`, select **Development Editor | Win64**, set `Seminole` as
+the startup project and **Build**. Rider: open the `.uproject` directly and
+build the `SeminoleEditor` configuration.
+
+### 4. Open the project
+
+Double-click `Seminole.uproject`, or run `scripts\open-project.bat`. The script
+uses `%UE_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe` when the `UE_ROOT`
+environment variable points at an engine install, and otherwise falls back to
+the `.uproject` file association. The editor starts on the engine map
+`/Engine/Maps/Entry`; it is empty until you play.
+
+### 5. Play
+
+Press **Play** (set *Spawn player at* to **Default Player Start** in the Play
+dropdown, see Troubleshooting) or **Play > Standalone Game**. You should see a
+grey 100 m x 100 m floor, a lit grey cylinder and a third-person camera.
+Controls: **W/A/S/D** move, **mouse** looks, **Space** jumps.
+
+### 6. Package for Windows
+
+**Platforms > Windows > Package Project** with the **Development**
+configuration. When asked for a folder, choose `Packaged` inside the
+repository (it is in `.gitignore`). The result is
+`Packaged\Windows\Seminole.exe`.
+
+### 7. Run the packaged game
+
+Launch `Packaged\Windows\Seminole.exe`, or run `scripts\run-latest-build.bat`,
+which starts that executable and exits with an error (code 1) when no packaged
+build exists yet.
+
+### 8. Optional: generate a test map
+
+`scripts/editor/create_test_map.py` creates and saves `/Game/Maps/L_TestMap`
+(a floor, a directional light, a sky light and a PlayerStart) so you have an
+editable map instead of the empty engine one. It needs the **Python Editor
+Script Plugin** (Edit > Plugins), which is not enabled in the repository. The
+file's header explains how to run it and how to point the config at the map.
+The runtime fallback keeps working whether or not the script has been run,
+and it spawns nothing on a map that already has those four actors. Do not
+commit the generated map unless a ticket asks for it.
+
+### Troubleshooting
+
+- **"The following modules are missing or built with a different engine
+  version" / rebuild prompt** when opening the `.uproject`: the editor DLLs
+  under `Binaries/` are stale or absent. Click **No**, build **Development
+  Editor | Win64** in Visual Studio or Rider, then open the project again.
+  (Clicking **Yes** also works for small changes but hides compile errors.)
+- **Project files out of date or Visual Studio does not list new source
+  files**: regenerate them (right-click `Seminole.uproject` > *Generate Visual
+  Studio project files*). Do this after adding or removing `.cpp`/`.h` files
+  or editing `*.Build.cs` / `*.Target.cs`. Deleting `Binaries/`,
+  `Intermediate/` and `Saved/` before regenerating gives a clean slate; they
+  are all ignored by Git.
+- **Wrong engine version**: if several engines are installed, right-click
+  `Seminole.uproject` > *Switch Unreal Engine version...* and pick 5.8, or set
+  `UE_ROOT` for `scripts\open-project.bat`.
+- **Assets are tiny text files starting with `version https://git-lfs...`**:
+  these are LFS pointer files, meaning Git LFS was not installed when you
+  cloned. Run `git lfs install` then `git lfs pull`.
+- **The character falls into darkness when pressing Play**: the editor spawned
+  the pawn at the viewport camera position (its default), which in the empty
+  engine map may sit below or outside the runtime floor. Use the Play dropdown
+  and set *Spawn player at* to **Default Player Start**, or move the viewport
+  camera above the origin.
+- **`run-latest-build.bat` reports no packaged build**: package first (step
+  6) and make sure the output folder is the repository's `Packaged` directory,
+  so the executable ends up at `Packaged\Windows\Seminole.exe`.
 
 The browser prototype below is a preview and design sandbox and keeps working
 independently of the Unreal project.
@@ -121,13 +221,17 @@ See [ASSETS_LICENSES.md](ASSETS_LICENSES.md).
 ```
 Seminole.uproject       Unreal Engine 5.8 project (module Seminole; no plugins enabled yet)
 Source/                 UE C++: Seminole.Target.cs, SeminoleEditor.Target.cs and the Seminole module
-  Seminole/             Module stub plus one README-only folder per planned system
+  Seminole/             Module (Seminole.Build.cs, Seminole.h/.cpp) and the bootstrap classes:
+                        SeminoleGameMode, SeminolePlaceholderCharacter, SeminoleTestEnvironment;
+                        plus one README-only folder per planned system
 Content/                UE assets (AI, Characters, Environments, Items, Maps, Missions, Weapons, UI, Audio); empty for now
-Config/                 UE DefaultEngine.ini, DefaultGame.ini, DefaultInput.ini
-Plugins/                Project plugins (none yet)
+Config/                 UE DefaultEngine.ini (maps, game mode), DefaultGame.ini (project, packaging), DefaultInput.ini (legacy mappings)
+scripts/                open-project.bat, run-latest-build.bat, editor/create_test_map.py
+Packaged/               Packaged game output (ignored by Git; created by Platforms > Windows > Package Project)
 docs/ARCHITECTURE.md    Technical foundation of the Unreal project
+docs/VALIDATION.md      Manual build / play / package checklist (not yet executed)
 docs/adr/               Architecture decision records
-.gitattributes          Git LFS rules for binary assets
+.gitattributes          Git LFS rules for binary assets; CRLF for .bat files
 
 index.html              Page shell, HUD, hover prompt and the How to Play panel container
 src/main.js             Entry point: checks for WebGL and starts the game
diff --git a/Source/Seminole/Characters/README.md b/Source/Seminole/Characters/README.md
index 356cc0e..9861776 100644
--- a/Source/Seminole/Characters/README.md
+++ b/Source/Seminole/Characters/README.md
@@ -3,4 +3,5 @@
 Player and NPC characters: the base character class, Enhanced Input bindings, movement and animation hooks.
 
 No code lives here yet; this folder fixes where the system lands when its ticket is funded.
+The temporary `ASeminolePlaceholderCharacter` in the module root (legacy input, no animation) is bootstrap scaffolding, not the start of this system.
 See [docs/ARCHITECTURE.md](../../../docs/ARCHITECTURE.md).
diff --git a/Source/Seminole/Seminole.Build.cs b/Source/Seminole/Seminole.Build.cs
index 2d1f7d1..5fd4954 100644
--- a/Source/Seminole/Seminole.Build.cs
+++ b/Source/Seminole/Seminole.Build.cs
@@ -13,8 +13,7 @@ public Seminole(ReadOnlyTargetRules Target) : base(Target)
 			"Core",
 			"CoreUObject",
 			"Engine",
-			"InputCore",
-			"EnhancedInput"
+			"InputCore"
 		});
 
 		PrivateDependencyModuleNames.AddRange(new string[] { });
diff --git a/Source/Seminole/Seminole.cpp b/Source/Seminole/Seminole.cpp
index 62adeec..686c456 100644
--- a/Source/Seminole/Seminole.cpp
+++ b/Source/Seminole/Seminole.cpp
@@ -3,4 +3,6 @@
 #include "Seminole.h"
 #include "Modules/ModuleManager.h"
 
+DEFINE_LOG_CATEGORY(LogSeminole);
+
 IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, Seminole, "Seminole");
diff --git a/Source/Seminole/Seminole.h b/Source/Seminole/Seminole.h
index ac66745..1a845bc 100644
--- a/Source/Seminole/Seminole.h
+++ b/Source/Seminole/Seminole.h
@@ -3,3 +3,6 @@
 #pragma once
 
 #include "CoreMinimal.h"
+#include "Logging/LogMacros.h"
+
+SEMINOLE_API DECLARE_LOG_CATEGORY_EXTERN(LogSeminole, Log, All);
diff --git a/Source/Seminole/SeminoleGameMode.cpp b/Source/Seminole/SeminoleGameMode.cpp
new file mode 100644
index 0000000..6e79c74
--- /dev/null
+++ b/Source/Seminole/SeminoleGameMode.cpp
@@ -0,0 +1,49 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "SeminoleGameMode.h"
+
+#include "Seminole.h"
+#include "SeminolePlaceholderCharacter.h"
+#include "SeminoleTestEnvironment.h"
+#include "Engine/World.h"
+#include "EngineUtils.h"
+
+ASeminoleGameMode::ASeminoleGameMode()
+{
+	DefaultPawnClass = ASeminolePlaceholderCharacter::StaticClass();
+}
+
+void ASeminoleGameMode::InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage)
+{
+	Super::InitGame(MapName, Options, ErrorMessage);
+
+	UWorld* World = GetWorld();
+	if (World == nullptr)
+	{
+		return;
+	}
+
+	// InitGame runs after the level's actors are registered and before the first player logs in,
+	// so anything spawned here (in particular the PlayerStart) is in place for the spawn.
+	ASeminoleTestEnvironment* TestEnvironment = nullptr;
+	for (TActorIterator<ASeminoleTestEnvironment> It(World); It; ++It)
+	{
+		TestEnvironment = *It;
+		break;
+	}
+
+	if (TestEnvironment == nullptr)
+	{
+		FActorSpawnParameters SpawnParams;
+		SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
+		TestEnvironment = World->SpawnActor<ASeminoleTestEnvironment>(SpawnParams);
+	}
+
+	if (TestEnvironment == nullptr)
+	{
+		UE_LOG(LogSeminole, Error, TEXT("SeminoleGameMode: could not spawn ASeminoleTestEnvironment in %s."), *MapName);
+		return;
+	}
+
+	TestEnvironment->EnsureSceneBasics();
+}
diff --git a/Source/Seminole/SeminoleGameMode.h b/Source/Seminole/SeminoleGameMode.h
new file mode 100644
index 0000000..a71e3a2
--- /dev/null
+++ b/Source/Seminole/SeminoleGameMode.h
@@ -0,0 +1,26 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "GameFramework/GameModeBase.h"
+#include "SeminoleGameMode.generated.h"
+
+/**
+ * Project default game mode (Config/DefaultEngine.ini, GlobalDefaultGameMode).
+ *
+ * Spawns ASeminolePlaceholderCharacter for each player and, before any player is spawned,
+ * makes sure the current map has a floor, a directional light, a sky light and a PlayerStart
+ * (see ASeminoleTestEnvironment). This lets the project boot into the empty engine map
+ * /Engine/Maps/Entry without any authored content.
+ */
+UCLASS()
+class SEMINOLE_API ASeminoleGameMode : public AGameModeBase
+{
+	GENERATED_BODY()
+
+public:
+	ASeminoleGameMode();
+
+	virtual void InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage) override;
+};
diff --git a/Source/Seminole/SeminolePlaceholderCharacter.cpp b/Source/Seminole/SeminolePlaceholderCharacter.cpp
new file mode 100644
index 0000000..7323186
--- /dev/null
+++ b/Source/Seminole/SeminolePlaceholderCharacter.cpp
@@ -0,0 +1,93 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "SeminolePlaceholderCharacter.h"
+
+#include "Camera/CameraComponent.h"
+#include "Components/CapsuleComponent.h"
+#include "Components/InputComponent.h"
+#include "Components/StaticMeshComponent.h"
+#include "Engine/CollisionProfile.h"
+#include "Engine/StaticMesh.h"
+#include "GameFramework/CharacterMovementComponent.h"
+#include "GameFramework/Controller.h"
+#include "GameFramework/SpringArmComponent.h"
+#include "UObject/ConstructorHelpers.h"
+
+ASeminolePlaceholderCharacter::ASeminolePlaceholderCharacter()
+{
+	PrimaryActorTick.bCanEverTick = false;
+
+	// Default capsule: radius 34, half height 88 (176 cm tall).
+	GetCapsuleComponent()->InitCapsuleSize(34.0f, 88.0f);
+
+	// The controller rotates the camera boom, not the pawn; the pawn turns to face its movement.
+	bUseControllerRotationPitch = false;
+	bUseControllerRotationYaw = false;
+	bUseControllerRotationRoll = false;
+
+	UCharacterMovementComponent* Movement = GetCharacterMovement();
+	Movement->bOrientRotationToMovement = true;
+	Movement->RotationRate = FRotator(0.0f, 540.0f, 0.0f);
+	Movement->JumpZVelocity = 600.0f;
+	Movement->AirControl = 0.2f;
+
+	BodyMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BodyMesh"));
+	BodyMesh->SetupAttachment(GetCapsuleComponent());
+	BodyMesh->SetCollisionProfileName(UCollisionProfile::NoCollision_ProfileName);
+	BodyMesh->SetGenerateOverlapEvents(false);
+	BodyMesh->SetCanEverAffectNavigation(false);
+	// Resolved here so the class default object hard-references the mesh and the cooker packages it.
+	ConstructorHelpers::FObjectFinder<UStaticMesh> CylinderFinder(TEXT("/Engine/BasicShapes/Cylinder.Cylinder"));
+	if (CylinderFinder.Succeeded())
+	{
+		BodyMesh->SetStaticMesh(CylinderFinder.Object);
+	}
+	// The engine cylinder is 100 cm wide and 100 cm tall; fit it inside the capsule.
+	BodyMesh->SetRelativeScale3D(FVector(0.68f, 0.68f, 1.76f));
+
+	CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
+	CameraBoom->SetupAttachment(RootComponent);
+	CameraBoom->TargetArmLength = 400.0f;
+	CameraBoom->SocketOffset = FVector(0.0f, 0.0f, 60.0f);
+	CameraBoom->bUsePawnControlRotation = true;
+
+	FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
+	FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName);
+	FollowCamera->bUsePawnControlRotation = false;
+}
+
+void ASeminolePlaceholderCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
+{
+	Super::SetupPlayerInputComponent(PlayerInputComponent);
+	check(PlayerInputComponent);
+
+	// Names match the AxisMappings / ActionMappings in Config/DefaultInput.ini.
+	PlayerInputComponent->BindAxis(TEXT("MoveForward"), this, &ASeminolePlaceholderCharacter::MoveForward);
+	PlayerInputComponent->BindAxis(TEXT("MoveRight"), this, &ASeminolePlaceholderCharacter::MoveRight);
+	PlayerInputComponent->BindAxis(TEXT("Turn"), this, &APawn::AddControllerYawInput);
+	PlayerInputComponent->BindAxis(TEXT("LookUp"), this, &APawn::AddControllerPitchInput);
+
+	PlayerInputComponent->BindAction(TEXT("Jump"), IE_Pressed, this, &ACharacter::Jump);
+	PlayerInputComponent->BindAction(TEXT("Jump"), IE_Released, this, &ACharacter::StopJumping);
+}
+
+void ASeminolePlaceholderCharacter::MoveForward(float Value)
+{
+	if (Controller != nullptr && Value != 0.0f)
+	{
+		// Move along the camera's yaw, ignoring its pitch, so looking down does not slow the pawn.
+		const FRotator YawRotation(0.0f, Controller->GetControlRotation().Yaw, 0.0f);
+		const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
+		AddMovementInput(Direction, Value);
+	}
+}
+
+void ASeminolePlaceholderCharacter::MoveRight(float Value)
+{
+	if (Controller != nullptr && Value != 0.0f)
+	{
+		const FRotator YawRotation(0.0f, Controller->GetControlRotation().Yaw, 0.0f);
+		const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
+		AddMovementInput(Direction, Value);
+	}
+}
diff --git a/Source/Seminole/SeminolePlaceholderCharacter.h b/Source/Seminole/SeminolePlaceholderCharacter.h
new file mode 100644
index 0000000..129271a
--- /dev/null
+++ b/Source/Seminole/SeminolePlaceholderCharacter.h
@@ -0,0 +1,44 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "GameFramework/Character.h"
+#include "SeminolePlaceholderCharacter.generated.h"
+
+class UCameraComponent;
+class USpringArmComponent;
+class UStaticMeshComponent;
+
+/**
+ * Stand-in player pawn used to prove the project boots: a capsule with a visible cylinder,
+ * a third-person spring-arm camera and WASD / mouse-look / jump controls.
+ *
+ * Input uses the legacy axis and action mappings in Config/DefaultInput.ini (MoveForward,
+ * MoveRight, Turn, LookUp, Jump), bound in SetupPlayerInputComponent. The real player
+ * character, with Enhanced Input and animation, is a later ticket under Characters/.
+ */
+UCLASS()
+class SEMINOLE_API ASeminolePlaceholderCharacter : public ACharacter
+{
+	GENERATED_BODY()
+
+public:
+	ASeminolePlaceholderCharacter();
+
+	virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
+
+private:
+	void MoveForward(float Value);
+	void MoveRight(float Value);
+
+	/** Visible body: /Engine/BasicShapes/Cylinder scaled to fit the capsule. No collision; the capsule handles that. */
+	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seminole", meta = (AllowPrivateAccess = "true"))
+	TObjectPtr<UStaticMeshComponent> BodyMesh;
+
+	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seminole", meta = (AllowPrivateAccess = "true"))
+	TObjectPtr<USpringArmComponent> CameraBoom;
+
+	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Seminole", meta = (AllowPrivateAccess = "true"))
+	TObjectPtr<UCameraComponent> FollowCamera;
+};
diff --git a/Source/Seminole/SeminoleTestEnvironment.cpp b/Source/Seminole/SeminoleTestEnvironment.cpp
new file mode 100644
index 0000000..ccf82ab
--- /dev/null
+++ b/Source/Seminole/SeminoleTestEnvironment.cpp
@@ -0,0 +1,216 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#include "SeminoleTestEnvironment.h"
+
+#include "Seminole.h"
+#include "CollisionQueryParams.h"
+#include "Components/DirectionalLightComponent.h"
+#include "Components/SkyLightComponent.h"
+#include "Components/StaticMeshComponent.h"
+#include "Engine/CollisionProfile.h"
+#include "Engine/DirectionalLight.h"
+#include "Engine/SkyLight.h"
+#include "Engine/StaticMesh.h"
+#include "Engine/StaticMeshActor.h"
+#include "Engine/World.h"
+#include "EngineUtils.h"
+#include "GameFramework/PlayerStart.h"
+#include "UObject/ConstructorHelpers.h"
+
+const FName ASeminoleTestEnvironment::FloorTag(TEXT("SeminoleFloor"));
+
+namespace
+{
+	// /Engine/BasicShapes/Cube is 1 m on each side; this scale gives a 100 m x 100 m x 1 m slab.
+	const FVector FloorScale(100.0f, 100.0f, 1.0f);
+	// Centre of the slab, so its top surface sits at Z = 0.
+	const FVector FloorLocation(0.0f, 0.0f, -50.0f);
+	// Above the floor by more than the default capsule half height (88), so the pawn drops onto it.
+	const FVector PlayerStartLocation(0.0f, 0.0f, 120.0f);
+	// Pitch -50 points the sun down; the yaw just keeps the shadows off-axis.
+	const FRotator SunRotation(-50.0f, 30.0f, 0.0f);
+
+	template <typename TComponent>
+	bool WorldHasComponentOfClass(const UWorld* World)
+	{
+		for (TActorIterator<AActor> It(World); It; ++It)
+		{
+			if (It->FindComponentByClass<TComponent>() != nullptr)
+			{
+				return true;
+			}
+		}
+		return false;
+	}
+}
+
+ASeminoleTestEnvironment::ASeminoleTestEnvironment()
+{
+	PrimaryActorTick.bCanEverTick = false;
+
+	ConstructorHelpers::FObjectFinder<UStaticMesh> CubeFinder(TEXT("/Engine/BasicShapes/Cube.Cube"));
+	if (CubeFinder.Succeeded())
+	{
+		FloorMesh = CubeFinder.Object;
+	}
+}
+
+void ASeminoleTestEnvironment::EnsureSceneBasics()
+{
+	if (GetWorld() == nullptr)
+	{
+		return;
+	}
+
+	// The floor goes first so the PlayerStart has something beneath it when it initialises.
+	if (!HasFloor())
+	{
+		SpawnFloor();
+	}
+	if (!HasDirectionalLight())
+	{
+		SpawnDirectionalLight();
+	}
+	if (!HasSkyLight())
+	{
+		SpawnSkyLight();
+	}
+	if (!HasPlayerStart())
+	{
+		SpawnPlayerStart();
+	}
+}
+
+bool ASeminoleTestEnvironment::HasFloor() const
+{
+	const UWorld* World = GetWorld();
+
+	// A floor spawned by this class or by create_test_map.py carries FloorTag.
+	for (TActorIterator<AStaticMeshActor> It(World); It; ++It)
+	{
+		if (It->ActorHasTag(FloorTag))
+		{
+			return true;
+		}
+	}
+
+	// Otherwise any static geometry under the origin counts, so hand-made maps keep their own ground.
+	FHitResult Hit;
+	const FCollisionQueryParams Params(TEXT("SeminoleFloorProbe"), /*bInTraceComplex*/ false);
+	const FVector ProbeStart(0.0f, 0.0f, 100000.0f);
+	const FVector ProbeEnd(0.0f, 0.0f, -100000.0f);
+	return World->LineTraceSingleByChannel(Hit, ProbeStart, ProbeEnd, ECC_WorldStatic, Params);
+}
+
+bool ASeminoleTestEnvironment::HasDirectionalLight() const
+{
+	return WorldHasComponentOfClass<UDirectionalLightComponent>(GetWorld());
+}
+
+bool ASeminoleTestEnvironment::HasSkyLight() const
+{
+	return WorldHasComponentOfClass<USkyLightComponent>(GetWorld());
+}
+
+bool ASeminoleTestEnvironment::HasPlayerStart() const
+{
+	TActorIterator<APlayerStart> It(GetWorld());
+	return static_cast<bool>(It);
+}
+
+void ASeminoleTestEnvironment::SpawnFloor()
+{
+	if (FloorMesh == nullptr)
+	{
+		UE_LOG(LogSeminole, Error, TEXT("SeminoleTestEnvironment: /Engine/BasicShapes/Cube not found, no floor spawned."));
+		return;
+	}
+
+	// Deferred spawn: the mesh and collision are set before the component registers, which is
+	// the only point at which the Static-mobility component of an AStaticMeshActor accepts a new mesh.
+	const FTransform FloorTransform(FRotator::ZeroRotator, FloorLocation, FloorScale);
+	AStaticMeshActor* Floor = GetWorld()->SpawnActorDeferred<AStaticMeshActor>(
+		AStaticMeshActor::StaticClass(), FloorTransform, this, nullptr,
+		ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
+	if (Floor == nullptr)
+	{
+		UE_LOG(LogSeminole, Error, TEXT("SeminoleTestEnvironment: failed to spawn the floor."));
+		return;
+	}
+
+	Floor->Tags.Add(FloorTag);
+#if WITH_EDITOR
+	Floor->SetActorLabel(TEXT("SeminoleFloor"));
+#endif
+
+	UStaticMeshComponent* FloorComponent = Floor->GetStaticMeshComponent();
+	FloorComponent->SetStaticMesh(FloorMesh);
+	FloorComponent->SetCollisionProfileName(UCollisionProfile::BlockAll_ProfileName);
+	FloorComponent->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
+
+	Floor->FinishSpawning(FloorTransform);
+	UE_LOG(LogSeminole, Log, TEXT("SeminoleTestEnvironment: spawned a %.0f m x %.0f m floor."), FloorScale.X, FloorScale.Y);
+}
+
+void ASeminoleTestEnvironment::SpawnDirectionalLight()
+{
+	const FTransform LightTransform(SunRotation, FVector(0.0f, 0.0f, 500.0f));
+	ADirectionalLight* Sun = GetWorld()->SpawnActorDeferred<ADirectionalLight>(
+		ADirectionalLight::StaticClass(), LightTransform, this, nullptr,
+		ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
+	if (Sun == nullptr)
+	{
+		UE_LOG(LogSeminole, Error, TEXT("SeminoleTestEnvironment: failed to spawn the directional light."));
+		return;
+	}
+
+#if WITH_EDITOR
+	Sun->SetActorLabel(TEXT("SeminoleSun"));
+#endif
+	// Runtime-spawned lights cannot have baked lighting, so they must be movable.
+	Sun->GetLightComponent()->SetMobility(EComponentMobility::Movable);
+
+	Sun->FinishSpawning(LightTransform);
+	UE_LOG(LogSeminole, Log, TEXT("SeminoleTestEnvironment: spawned a movable directional light."));
+}
+
+void ASeminoleTestEnvironment::SpawnSkyLight()
+{
+	const FTransform LightTransform(FRotator::ZeroRotator, FVector(0.0f, 0.0f, 500.0f));
+	ASkyLight* Sky = GetWorld()->SpawnActorDeferred<ASkyLight>(
+		ASkyLight::StaticClass(), LightTransform, this, nullptr,
+		ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
+	if (Sky == nullptr)
+	{
+		UE_LOG(LogSeminole, Error, TEXT("SeminoleTestEnvironment: failed to spawn the sky light."));
+		return;
+	}
+
+#if WITH_EDITOR
+	Sky->SetActorLabel(TEXT("SeminoleSkyLight"));
+#endif
+	Sky->GetLightComponent()->SetMobility(EComponentMobility::Movable);
+
+	Sky->FinishSpawning(LightTransform);
+	UE_LOG(LogSeminole, Log, TEXT("SeminoleTestEnvironment: spawned a movable sky light."));
+}
+
+void ASeminoleTestEnvironment::SpawnPlayerStart()
+{
+	FActorSpawnParameters SpawnParams;
+	SpawnParams.Owner = this;
+	SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
+
+	APlayerStart* Start = GetWorld()->SpawnActor<APlayerStart>(
+		APlayerStart::StaticClass(), PlayerStartLocation, FRotator::ZeroRotator, SpawnParams);
+	if (Start == nullptr)
+	{
+		UE_LOG(LogSeminole, Error, TEXT("SeminoleTestEnvironment: failed to spawn the PlayerStart."));
+		return;
+	}
+
+#if WITH_EDITOR
+	Start->SetActorLabel(TEXT("SeminolePlayerStart"));
+#endif
+	UE_LOG(LogSeminole, Log, TEXT("SeminoleTestEnvironment: spawned a PlayerStart at %s."), *PlayerStartLocation.ToString());
+}
diff --git a/Source/Seminole/SeminoleTestEnvironment.h b/Source/Seminole/SeminoleTestEnvironment.h
new file mode 100644
index 0000000..67a2758
--- /dev/null
+++ b/Source/Seminole/SeminoleTestEnvironment.h
@@ -0,0 +1,55 @@
+// Copyright Seminole contributors. MIT licence; see LICENSE.
+
+#pragma once
+
+#include "CoreMinimal.h"
+#include "GameFramework/Actor.h"
+#include "SeminoleTestEnvironment.generated.h"
+
+class UStaticMesh;
+
+/**
+ * Spawns the minimum scene a map needs to boot and be walked around in: a floor,
+ * a directional light, a sky light and a PlayerStart.
+ *
+ * ASeminoleGameMode creates one of these in InitGame (before any player is spawned)
+ * and calls EnsureSceneBasics(). Each piece is only spawned when the world has none,
+ * so a hand-made map or the map generated by scripts/editor/create_test_map.py
+ * receives no duplicates. The game boots into the empty engine map
+ * /Engine/Maps/Entry by default, where all four pieces are missing.
+ *
+ * This is bootstrap scaffolding, not a gameplay system: it goes away once a real
+ * map ships. Nothing here is replicated.
+ */
+UCLASS()
+class SEMINOLE_API ASeminoleTestEnvironment : public AActor
+{
+	GENERATED_BODY()
+
+public:
+	ASeminoleTestEnvironment();
+
+	/** Actor tag carried by every floor this class (or create_test_map.py) spawns. */
+	static const FName FloorTag;
+
+	/** Spawns whichever of the floor, directional light, sky light and PlayerStart the world is missing. */
+	void EnsureSceneBasics();
+
+private:
+	bool HasFloor() const;
+	bool HasDirectionalLight() const;
+	bool HasSkyLight() const;
+	bool HasPlayerStart() const;
+
+	void SpawnFloor();
+	void SpawnDirectionalLight();
+	void SpawnSkyLight();
+	void SpawnPlayerStart();
+
+	/**
+	 * /Engine/BasicShapes/Cube, resolved in the constructor so the class default object
+	 * hard-references it and the cooker packages it alongside the game.
+	 */
+	UPROPERTY()
+	TObjectPtr<UStaticMesh> FloorMesh;
+};
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 0ec1b4d..3c8b6f7 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -24,7 +24,10 @@ that implements a system owns the details and updates this file.
   classes derive from C++ bases and are not expected to contain system logic.
   See [ADR 0002](adr/0002-cpp-blueprint-hybrid.md).
 * Enhanced Input for all player input; Input Actions and Mapping Contexts are
-  data assets under `Content/`.
+  data assets under `Content/`. Until the Characters ticket lands, the
+  bootstrap `ASeminolePlaceholderCharacter` (module root) uses legacy
+  axis/action mappings from `Config/DefaultInput.ini` so the project boots
+  without any input assets; it is replaced, not extended.
 * No Gameplay Ability System by default. Health, stamina, damage and status
   effects are plain components and data assets until a concrete need appears.
   See [ADR 0007](adr/0007-no-gameplay-ability-system-by-default.md).
@@ -181,14 +184,16 @@ Every component that can make noise reports it through the noise subsystem.
 ## Source control
 
 * **Git with Git LFS**. `.gitattributes` routes `.uasset`, `.umap`, `.fbx`,
-  `.wav`, `.png`, `.tga`, `.psd`, `.blend`, `.spp`, `.sbs` and `.sbsar`
-  through LFS. See [ADR 0008](adr/0008-git-lfs-before-perforce.md).
+  `.png`, `.tga`, `.jpg`, `.exr`, `.psd`, `.wav`, `.mp3`, `.ogg`, `.blend`,
+  `.spp`, `.sbs` and `.sbsar` through LFS. See
+  [ADR 0008](adr/0008-git-lfs-before-perforce.md).
 * Binary assets cannot be merged. Until file locking is needed, coordinate
   through tickets: one open ticket owns a given map or asset at a time. Keep
   maps small and split content with World Partition data layers and One File
   Per Actor to reduce conflicts.
 * `.gitignore` excludes `Binaries/`, `Intermediate/`, `Saved/`,
-  `DerivedDataCache/` and IDE files. `Content/` and `Config/` are tracked.
+  `DerivedDataCache/`, IDE files and the packaged output folder `Packaged/`.
+  `Content/` and `Config/` are tracked.
 * Perforce is a possible later move if LFS becomes a bottleneck; nothing in
   the layout prevents it.
 
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
new file mode 100644
index 0000000..4e07154
--- /dev/null
+++ b/docs/VALIDATION.md
@@ -0,0 +1,106 @@
+# Manual validation checklist
+
+**None of the steps below were executed by the implementer of ticket #22.** The change was
+written and reviewed statically, in an environment without Unreal Engine or Windows. Compiling,
+launching, playing and packaging have not been verified. The first person with Unreal Engine
+5.8 on Windows should walk through this list and record the result (and any fixes) on the
+pull request or in a follow-up ticket.
+
+Prerequisites: Windows 10/11, Unreal Engine 5.8 (Epic Games Launcher), Visual Studio 2022 with
+the "Game development with C++" workload (or Rider), Git with Git LFS. Follow the README's
+[Getting Started](../README.md#getting-started) up to and including generating project files.
+
+Tick each box only when the observed result matches.
+
+## 1. Build the editor target
+
+- [ ] Right-click `Seminole.uproject` > **Generate Visual Studio project files** completes
+      without errors and produces `Seminole.sln` (ignored by Git).
+- [ ] In Visual Studio, configuration **Development Editor | Win64**, project `Seminole`:
+      **Build** succeeds with no errors. (Warnings about the deprecated legacy input path are
+      acceptable; errors are not.)
+- [ ] The build produced `Binaries/Win64/UnrealEditor-Seminole.dll`.
+
+## 2. Open the editor and the startup map
+
+- [ ] `scripts\open-project.bat` (or double-clicking `Seminole.uproject`) opens Unreal Editor
+      5.8 without a "missing modules / rebuild" prompt.
+- [ ] The editor starts on the startup map `/Engine/Maps/Entry` (`Config/DefaultEngine.ini`,
+      `EditorStartupMap`). This is an empty engine map, so the editor viewport is empty before
+      play; that is expected.
+- [ ] **Edit > Project Settings > Maps & Modes**: Default GameMode is `SeminoleGameMode`,
+      Default Pawn Class is `SeminolePlaceholderCharacter`, Editor Startup Map and Game Default
+      Map are both `Entry`.
+- [ ] **Edit > Project Settings > Input**: the Axis Mappings `MoveForward` (W/S),
+      `MoveRight` (D/A), `Turn` (Mouse X), `LookUp` (Mouse Y, scale -1) and the Action Mapping
+      `Jump` (Space Bar) are listed; Default Player Input Class is `PlayerInput`, Default Input
+      Component Class is `InputComponent`.
+
+## 3. Play In Editor: scene, movement, look, jump
+
+Before pressing Play, set the Play button's dropdown **Spawn player at: Default Player Start**
+(the editor otherwise spawns at the viewport camera, which in the empty map may be below the
+runtime-spawned floor).
+
+- [ ] Press **Play** (Selected Viewport or New Editor Window). The Output Log (`LogSeminole`)
+      shows four lines: spawned a floor, spawned a movable directional light, spawned a movable
+      sky light, spawned a PlayerStart.
+- [ ] The scene shows a large flat grey floor, lit by a directional light, with a grey cylinder
+      (the placeholder character) standing on it, viewed from a third-person camera behind it.
+      The sky is black (there is no atmosphere in this shell); that is expected.
+- [ ] The World Outliner lists `SeminoleTestEnvironment`, `SeminoleFloor`, `SeminoleSun`,
+      `SeminoleSkyLight` and `SeminolePlayerStart` (only during play).
+- [ ] **W / S** move forward / back and **A / D** strafe left / right, relative to the camera.
+      The cylinder turns to face the direction it moves.
+- [ ] Moving the **mouse** orbits the camera around the character (left/right = yaw, up/down =
+      pitch; moving the mouse up looks up).
+- [ ] **Space** jumps; the character leaves the floor and lands on it again.
+- [ ] Walking to the edge of the floor (50 m from the start) and off it makes the character
+      fall, confirming the floor has collision and nothing else is holding the pawn.
+- [ ] Press Play a second time: still exactly four `LogSeminole` spawn lines (no duplicates
+      accumulate between sessions).
+- [ ] **Play > Standalone Game** boots the same scene with the same controls.
+
+## 4. Package Win64 Development
+
+- [ ] **Platforms > Windows > Package Project** (Binary Configuration: **Development**,
+      build target `Seminole`). When asked for an output folder, choose `<repo>\Packaged`.
+- [ ] Packaging finishes with "BUILD SUCCESSFUL" and produces
+      `Packaged\Windows\Seminole.exe` (plus `Seminole\` and `Engine\` folders beside it).
+- [ ] `git status` shows nothing new: `Packaged/`, `Binaries/`, `Intermediate/`, `Saved/`,
+      `DerivedDataCache/` and `*.sln` are all ignored.
+
+## 5. Run the packaged game
+
+- [ ] `scripts\run-latest-build.bat` launches the game. (Also check the failure path: rename
+      `Packaged\Windows\Seminole.exe` temporarily, run the script, confirm it prints the
+      "No packaged build found" error and `echo %errorlevel%` prints `1`, then rename back.)
+- [ ] The packaged game loads the same scene as PIE: floor, lighting, cylinder character.
+      (If the floor is missing or the mesh is the default grey checker, `/Engine/BasicShapes`
+      was not cooked; note it on the PR.)
+- [ ] WASD, mouse look and Space behave as in section 3.
+- [ ] `Alt+F4` closes the game cleanly.
+
+## 6. Optional: generate and boot L_TestMap
+
+- [ ] **Edit > Plugins**, enable **Python Editor Script Plugin**, restart the editor.
+- [ ] **Tools > Execute Python Script...**, choose `scripts/editor/create_test_map.py`. The
+      Output Log shows `create_test_map: saved /Game/Maps/L_TestMap (spawned: floor,
+      directional light, sky light, PlayerStart)` and `Content/Maps/L_TestMap.umap` exists.
+- [ ] Open `/Game/Maps/L_TestMap` in the editor: it contains `SeminoleFloor`, `SeminoleSun`,
+      `SeminoleSkyLight` and `SeminolePlayerStart`.
+- [ ] Press Play on L_TestMap: the Output Log shows **no** `LogSeminole` spawn lines (the game
+      mode found everything already in place), and the scene and controls match section 3.
+- [ ] Running the script a second time logs `spawned: nothing, all four pieces were present`
+      and adds no actors.
+- [ ] Following the script header, point `GameDefaultMap` / `EditorStartupMap` at
+      `/Game/Maps/L_TestMap.L_TestMap`, add it to `MapsToCook`, repackage and confirm the
+      packaged game boots into it. Revert these local config changes and do not commit the
+      `.umap` or the `.uproject` plugin change unless a ticket asks for it.
+
+## Reporting
+
+Record for each section: pass / fail, engine version shown in the editor's title bar, and the
+exact error text for any failure (the Output Log for editor issues, the packaging log for
+section 4, `Packaged\Windows\Seminole\Saved\Logs\Seminole.log` or
+`%LOCALAPPDATA%\Seminole\Saved\Logs\Seminole.log` for section 5).
diff --git a/scripts/editor/create_test_map.py b/scripts/editor/create_test_map.py
new file mode 100644
index 0000000..ec1cb9c
--- /dev/null
+++ b/scripts/editor/create_test_map.py
@@ -0,0 +1,151 @@
+"""Create and save the local test map /Game/Maps/L_TestMap (Content/Maps/L_TestMap.umap).
+
+The map contains the same four things ASeminoleGameMode spawns at runtime when a map lacks
+them: a 100 m x 100 m floor (/Engine/BasicShapes/Cube, tagged "SeminoleFloor"), a movable
+directional light, a movable sky light and a PlayerStart above the floor. Running the game on
+this map therefore spawns nothing extra. The script is OPTIONAL: the project boots without it.
+
+Requirements
+------------
+* Unreal Editor 5.8 with the project built (SeminoleEditor target).
+* The "Python Editor Script Plugin" (PythonScriptPlugin), which is NOT enabled in
+  Seminole.uproject. Enable it locally: Edit > Plugins, search "Python Editor Script Plugin",
+  tick Enabled, restart the editor. Do not commit the resulting .uproject change unless a
+  ticket asks for it.
+
+How to run
+----------
+Either of these, from the editor:
+
+* Tools > Execute Python Script... and pick this file.
+* Output Log: switch the command line from "Cmd" to "Python" and enter
+      py "<repo>/scripts/editor/create_test_map.py"
+
+Or headless, from a terminal (adjust paths):
+
+    "<UE_ROOT>\\Engine\\Binaries\\Win64\\UnrealEditor-Cmd.exe" "<repo>\\Seminole.uproject" ^
+        -run=pythonscript -script="<repo>\\scripts\\editor\\create_test_map.py"
+
+Running the script again on an existing L_TestMap only adds whatever is missing.
+
+Afterwards: booting into the generated map
+------------------------------------------
+By default the project boots into the engine map /Engine/Maps/Entry. To boot into L_TestMap:
+
+1. Config/DefaultEngine.ini, section [/Script/EngineSettings.GameMapsSettings]:
+       GameDefaultMap=/Game/Maps/L_TestMap.L_TestMap
+       EditorStartupMap=/Game/Maps/L_TestMap.L_TestMap
+2. Config/DefaultGame.ini, section [/Script/UnrealEd.ProjectPackagingSettings]: add
+       +MapsToCook=(FilePath="/Game/Maps/L_TestMap")
+   so a packaged build includes it.
+
+The map is generated locally. .umap files go through Git LFS (.gitattributes); do not commit
+L_TestMap unless a ticket calls for a committed map.
+"""
+
+import unreal
+
+MAP_PACKAGE_PATH = "/Game/Maps/L_TestMap"
+FLOOR_MESH_PATH = "/Engine/BasicShapes/Cube.Cube"
+FLOOR_TAG = "SeminoleFloor"
+
+# Same layout as ASeminoleTestEnvironment (Source/Seminole/SeminoleTestEnvironment.cpp).
+FLOOR_LOCATION = unreal.Vector(0.0, 0.0, -50.0)  # 1 m thick slab, top surface at Z = 0
+FLOOR_SCALE = unreal.Vector(100.0, 100.0, 1.0)  # cube is 1 m, so 100 m x 100 m x 1 m
+PLAYER_START_LOCATION = unreal.Vector(0.0, 0.0, 120.0)
+LIGHT_LOCATION = unreal.Vector(0.0, 0.0, 500.0)
+# unreal.Rotator takes (roll, pitch, yaw).
+SUN_ROTATION = unreal.Rotator(0.0, -50.0, 30.0)
+
+
+def _has_component(actors, component_class):
+    return any(actor.get_component_by_class(component_class) is not None for actor in actors)
+
+
+def _has_floor(actors):
+    return any(
+        isinstance(actor, unreal.StaticMeshActor) and FLOOR_TAG in [str(tag) for tag in actor.tags]
+        for actor in actors
+    )
+
+
+def _has_player_start(actors):
+    return any(isinstance(actor, unreal.PlayerStart) for actor in actors)
+
+
+def _spawn(actor_subsystem, actor_class, location, rotation=None, label=None):
+    actor = actor_subsystem.spawn_actor_from_class(actor_class, location, rotation or unreal.Rotator())
+    if actor is None:
+        raise RuntimeError("Could not spawn {}".format(actor_class.__name__))
+    if label:
+        actor.set_actor_label(label)
+    return actor
+
+
+def _spawn_floor(actor_subsystem):
+    mesh = unreal.load_asset(FLOOR_MESH_PATH)
+    if mesh is None:
+        raise RuntimeError("Could not load {}".format(FLOOR_MESH_PATH))
+    floor = _spawn(actor_subsystem, unreal.StaticMeshActor, FLOOR_LOCATION, label="SeminoleFloor")
+    floor.set_actor_scale3d(FLOOR_SCALE)
+    floor.set_editor_property("tags", [unreal.Name(FLOOR_TAG)])
+    component = floor.static_mesh_component
+    component.set_editor_property("static_mesh", mesh)
+    component.set_collision_profile_name(unreal.Name("BlockAll"))
+    component.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
+    return floor
+
+
+def _spawn_directional_light(actor_subsystem):
+    light = _spawn(actor_subsystem, unreal.DirectionalLight, LIGHT_LOCATION, SUN_ROTATION, label="SeminoleSun")
+    light.light_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE)
+    return light
+
+
+def _spawn_sky_light(actor_subsystem):
+    light = _spawn(actor_subsystem, unreal.SkyLight, LIGHT_LOCATION, label="SeminoleSkyLight")
+    light.light_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE)
+    return light
+
+
+def _spawn_player_start(actor_subsystem):
+    return _spawn(actor_subsystem, unreal.PlayerStart, PLAYER_START_LOCATION, label="SeminolePlayerStart")
+
+
+def main():
+    level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
+    actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
+
+    if unreal.EditorAssetLibrary.does_asset_exist(MAP_PACKAGE_PATH):
+        unreal.log("create_test_map: {} exists, opening it and adding only what is missing".format(MAP_PACKAGE_PATH))
+        if not level_subsystem.load_level(MAP_PACKAGE_PATH):
+            raise RuntimeError("Could not load {}".format(MAP_PACKAGE_PATH))
+    else:
+        unreal.log("create_test_map: creating empty level {}".format(MAP_PACKAGE_PATH))
+        if not level_subsystem.new_level(MAP_PACKAGE_PATH):
+            raise RuntimeError("Could not create {}".format(MAP_PACKAGE_PATH))
+
+    actors = actor_subsystem.get_all_level_actors()
+    spawned = []
+
+    if not _has_floor(actors):
+        _spawn_floor(actor_subsystem)
+        spawned.append("floor")
+    if not _has_component(actors, unreal.DirectionalLightComponent):
+        _spawn_directional_light(actor_subsystem)
+        spawned.append("directional light")
+    if not _has_component(actors, unreal.SkyLightComponent):
+        _spawn_sky_light(actor_subsystem)
+        spawned.append("sky light")
+    if not _has_player_start(actors):
+        _spawn_player_start(actor_subsystem)
+        spawned.append("PlayerStart")
+
+    if not level_subsystem.save_current_level():
+        raise RuntimeError("Could not save {}".format(MAP_PACKAGE_PATH))
+
+    unreal.log("create_test_map: saved {} (spawned: {})".format(
+        MAP_PACKAGE_PATH, ", ".join(spawned) if spawned else "nothing, all four pieces were present"))
+
+
+main()
diff --git a/scripts/open-project.bat b/scripts/open-project.bat
new file mode 100644
index 0000000..84470de
--- /dev/null
+++ b/scripts/open-project.bat
@@ -0,0 +1,50 @@
+@echo off
+rem Opens Seminole.uproject in Unreal Editor 5.8.
+rem
+rem   1. If UE_ROOT is set and %UE_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe exists, that editor is used.
+rem      Example:  set "UE_ROOT=path\to\your\UE_5.8"
+rem   2. Otherwise the .uproject is opened through the Windows file association (set up by the Epic Games Launcher).
+rem
+rem The repository root is resolved relative to this script, so it can be run from any directory.
+setlocal
+
+set "REPO_ROOT=%~dp0.."
+for %%I in ("%REPO_ROOT%") do set "REPO_ROOT=%%~fI"
+set "UPROJECT=%REPO_ROOT%\Seminole.uproject"
+
+if not exist "%UPROJECT%" (
+    echo [open-project] ERROR: Seminole.uproject not found at "%UPROJECT%".
+    exit /b 1
+)
+
+if not defined UE_ROOT goto :use_association
+
+set "EDITOR_EXE=%UE_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe"
+if not exist "%EDITOR_EXE%" (
+    echo [open-project] UE_ROOT is set to "%UE_ROOT%" but "%EDITOR_EXE%" does not exist.
+    echo [open-project] Falling back to the .uproject file association.
+    goto :use_association
+)
+
+echo [open-project] Launching "%EDITOR_EXE%" with "%UPROJECT%"
+start "" "%EDITOR_EXE%" "%UPROJECT%"
+exit /b 0
+
+:use_association
+assoc .uproject >nul 2>&1
+if errorlevel 1 (
+    echo [open-project] ERROR: No Unreal Editor found.
+    echo [open-project]   - .uproject files have no file association on this machine, and
+    echo [open-project]   - UE_ROOT is not set to an Unreal Engine 5.8 installation.
+    echo [open-project] Install Unreal Engine 5.8 from the Epic Games Launcher, or set UE_ROOT, for example:
+    echo [open-project]   set "UE_ROOT=path\to\your\UE_5.8"
+    exit /b 1
+)
+
+echo [open-project] Opening "%UPROJECT%" through the .uproject file association
+start "" "%UPROJECT%"
+if errorlevel 1 (
+    echo [open-project] ERROR: Windows could not open "%UPROJECT%". Set UE_ROOT to your Unreal Engine 5.8 installation and retry.
+    exit /b 1
+)
+exit /b 0
diff --git a/scripts/run-latest-build.bat b/scripts/run-latest-build.bat
new file mode 100644
index 0000000..d3a3953
--- /dev/null
+++ b/scripts/run-latest-build.bat
@@ -0,0 +1,20 @@
+@echo off
+rem Launches the most recent packaged Windows build: Packaged\Windows\Seminole.exe (relative to the repository root).
+rem Package it from the editor first: Platforms / Windows / Package Project (Development), output folder "Packaged".
+rem Any arguments are passed through to the game executable.
+setlocal
+
+set "REPO_ROOT=%~dp0.."
+for %%I in ("%REPO_ROOT%") do set "REPO_ROOT=%%~fI"
+set "GAME_EXE=%REPO_ROOT%\Packaged\Windows\Seminole.exe"
+
+if not exist "%GAME_EXE%" (
+    echo [run-latest-build] ERROR: No packaged build found at "%GAME_EXE%".
+    echo [run-latest-build] Package the project first: in Unreal Editor choose Platforms ^> Windows ^> Package Project
+    echo [run-latest-build] with the Development configuration and select "%REPO_ROOT%\Packaged" as the output folder.
+    exit /b 1
+)
+
+echo [run-latest-build] Launching "%GAME_EXE%"
+start "" "%GAME_EXE%" %*
+exit /b 0
Acceptance · round 1
Shipped
CIPassed
Automated reviewPass with concerns

This is a thorough, well-documented static implementation: a GameMode plus test-environment actor that spawns a guarded floor, lights and PlayerStart; a legacy-input placeholder character; correct config; both launch scripts; an optional Python map generator; and complete Getting Started and VALIDATION docs that are honest about nothing being executed. The code looks idiomatic, but CI build was skipped and nothing has been compiled, so runtime correctness depends on the manual checklist. Some criteria (the uproject, pre-existing ignore/LFS lines, the cleanup removal list in a truncated PR description) cannot be confirmed from the visible diff.

Acceptance criteria · 9 of 12 met
  • UNCLEARSeminole.uproject parses as JSON, has one Runtime module Seminole, EngineAssociation 5.8, and no excluded pluginsThe uproject is not in the diff; the builder states it is unchanged with EngineAssociation 5.8, one Runtime/Default module and an empty Plugins list.
  • YESTarget.cs/Build.cs names match the files on disk; every #include resolves to a repo file or a Build.cs moduleSeminole.Build.cs now lists only Core/CoreUObject/Engine/InputCore, and all new includes (Engine/StaticMeshActor.h, GameFramework/SpringArmComponent.h, EngineUtils.h, etc.) live in Engine/CoreUObject or the repo; the Target files are unchanged and not shown.
  • YESASeminoleGameMode and ASeminolePlaceholderCharacter exist and C++ asset paths are only /Engine/BasicShapes/*Both classes are added; the only asset paths are /Engine/BasicShapes/Cylinder.Cylinder and /Engine/BasicShapes/Cube.Cube, loaded via FObjectFinder.
  • YESSpawn logic covers floor, directional light, sky light and PlayerStart, each guarded by an existence check`ASeminoleTestEnvironment::EnsureSceneBasics` guards each spawn with HasFloor/HasDirectionalLight/HasSkyLight/HasPlayerStart; lights are set Movable and the floor is 100x100x1 Cube with BlockAll collision.
  • YESDefaultEngine.ini sets GameDefaultMap, EditorStartupMap and GlobalDefaultGameMode to an engine map or a repo C++ classBoth maps are /Engine/Maps/Entry.Entry and the game mode is /Script/Seminole.SeminoleGameMode, which matches the new UCLASS.
  • YESDefaultInput.ini defines MoveForward/MoveRight/Turn/LookUp/Jump plus the legacy class overrides, and the character binds the same namesThe ini adds all mappings and sets PlayerInput/InputComponent; `SetupPlayerInputComponent` uses BindAxis/BindAction with identical names.
  • YEScreate_test_map.py exists and creates /Game/Maps/L_TestMap with the four elements; no .umap/.uasset committed`scripts/editor/create_test_map.py` creates or loads the level, adds the tagged floor, both lights and a PlayerStart, then saves; it has a full how-to header and no binary assets appear in the diff.
  • PARTIAL.gitignore and .gitattributes match section 1, with no tracked generated foldersThe diff adds Packaged/ to .gitignore and jpg/exr/mp3/ogg to the LFS rules; the remaining required entries are pre-existing and not visible in the diff.
  • YESBoth .bat scripts exist with no absolute engine paths and explicit errors; the run script exits non-zero when the exe is missingopen-project.bat resolves the root via %~dp0 and uses UE_ROOT or the file association with clear errors; run-latest-build.bat does `exit /b 1` with a message when Packaged\Windows\Seminole.exe is missing.
  • YESREADME Getting Started and docs/VALIDATION.md cover every listed itemREADME steps 1–8 plus Troubleshooting cover all required items; VALIDATION.md covers build, editor, PIE, package, exe and optional L_TestMap, and states none of the steps were executed.
  • PARTIALPR description states compile/launch/package were not run and lists removed filesThe summary states clearly that nothing was compiled, launched or packaged, but it is truncated before any removal list for Plugins/.gitkeep.
  • YESNo gameplay systems, online/Steam config or art assetsOnly bootstrap classes, config, scripts and docs are added; Enhanced Input is removed from Build.cs.
Concerns
  • Nothing was compiled or run: CI `build` was skipped and the agent had no engine, so the new C++ (deferred spawns, the line trace in `InitGame`, `ConstructorHelpers` use) is untested until someone works through `docs/VALIDATION.md`.
  • The builder summary is truncated, so I cannot confirm the PR description lists the cleanup removal (`Plugins/.gitkeep`, deleted in the diff) with a reason, as criterion 11 requires.
  • The spec names `Scripts/Editor/create_test_map.py`, but the file is at `scripts/editor/create_test_map.py`. This is harmless on Windows and consistent with `scripts/*.bat`, but it differs from the spec's casing.
  • `Seminole.uproject`, both Target.cs files and the pre-existing `.gitignore`/`.gitattributes` lines are not in the diff. Their compliance (EngineAssociation 5.8, plugin list, Binaries/Intermediate/Saved/DDC/.vs/.idea/*.sln ignores, uasset/umap/fbx/png/tga/wav/psd LFS rules) rests on the builder's claims and ARCHITECTURE.md text, not on visible evidence.
  • `HasFloor()` treats any `ECC_WorldStatic` hit along the vertical line through the origin as a floor. A map with, say, a small prop at the origin and no ground would skip the floor spawn. This is acceptable for bootstrap scaffolding but worth knowing.

BackersAccepted
1 accept · 0 rebuild · 0 not voted · quorum 1 of 1
MaintainerMerge

Accepted by the backers and merged by the maintainer.

Ballots · 1
AcceptJonathan Miller

Automated review cost $0.20, counted as builder cost.

Discussion · 0

No comments yet.