Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / 8e95ce3

sdl_runtime_compiler git · main

SDL3 game for running and compiling code at runtime

sdl3 c++ compiler dlopen cmake · first commit 2026-04-19 · last commit 2026-07-03 (3 months ago) · synced 3 days ago · upstream: git.ide3.de/hsnr/sdl-runtime-compiler

C++ 72.3% C 26.2%
git clone https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler.gitwget https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler/archive/sdl_runtime_compiler.tar.gz

Cleanup game_state sort out level direction With Claude

Christian Immanuel · 2026-06-06 01:04 · 8e95ce3b6f44f3930370d5a3f78225ec8963112a

 src/states/game_context.cpp    |  42 +++++
 src/states/game_context.hpp    |  14 +-
 src/states/game_state.cpp      | 371 +++++++++--------------------------------
 src/states/game_state.hpp      |  49 +-----
 src/systems/level_director.cpp | 227 +++++++++++++++++++++++++
 src/systems/level_director.hpp |  89 ++++++++++
 6 files changed, 451 insertions(+), 341 deletions(-)

diff --git a/src/states/game_context.cpp b/src/states/game_context.cpp
new file mode 100644
index 0000000..0d064ec
--- /dev/null
+++ b/src/states/game_context.cpp
@@ -0,0 +1,42 @@
+#include "states/game_context.hpp"
+
+#include <algorithm>
+
+#include "text_3d/text_3d.hpp"
+
+
+void
+GameContext::drop_WordToFloor(Text3dPtr word, const Vec3& vel)
+{
+    if (!word) return;
+    word->set_Look(ConfigColor::keep, Text3d::depth_floor);
+    word->body.mode = PhysicsMode::Dynamic;
+    word->body.vel  = vel;
+    words.push_back(std::move(word));
+}
+
+
+void
+GameContext::clear_WallRole(Wall::Role role)
+{
+    wall_texts.erase(
+        std::remove_if(wall_texts.begin(), wall_texts.end(),
+            [role](const TextWallPtr& wt){ return wt->role == role; }),
+        wall_texts.end());
+}
+
+
+void
+GameContext::add_WallText(Wall::Position pos, Wall::Role role, Text3dPtr t)
+{
+    wall_texts.push_back(cpputils::make_unique<TextWall>(
+        TextWall{ pos, role, std::move(t) }));
+}
+
+
+void
+GameContext::remove_WallText(size_t index)
+{
+    if (index < wall_texts.size())
+        wall_texts.erase(wall_texts.begin() + static_cast<std::ptrdiff_t>(index));
+}
diff --git a/src/states/game_context.hpp b/src/states/game_context.hpp
index c9402d3..d42b88b 100644
--- a/src/states/game_context.hpp
+++ b/src/states/game_context.hpp
@@ -1,11 +1,13 @@
 #pragma once
 
 #include <memory>
+#include <string>
 
 #include "entities/game_object.hpp"
 #include "text_3d/text_3d.hpp"
 #include "utils/globals.hpp"
 #include "wall/text_wall.hpp"
+#include "wall/wall.hpp"
 
 class  Player;
 class  Camera;
@@ -20,9 +22,13 @@ struct GameContext
     cpputils::unique_ptr<World>  world;
     cpputils::unique_ptr<Light>  light;
 
-    DynArray<Text3dPtr>       words;
-    DynArray<GameObjectPtr>   scenery;
-    DynArray<PultButton*>     buttons;
+    DynArray<Text3dPtr>     words;
+    DynArray<GameObjectPtr> scenery;
+    DynArray<PultButton*>   buttons;
+    TextWallList            wall_texts;
 
-    TextWallList                 wall_texts;
+    void drop_WordToFloor(Text3dPtr word, const Vec3& vel);
+    void clear_WallRole  (Wall::Role role);
+    void add_WallText    (Wall::Position pos, Wall::Role role, Text3dPtr t);
+    void remove_WallText (size_t index);
 };
diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index 0a88673..936a145 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -43,30 +43,28 @@ GameState::GameState(size_t starting_level)
     _particle_sys    = cpputils::make_unique<ParticleSystem>   ();
     _dyn_loader_sys  = cpputils::make_unique<DynLoaderSystem>  ();
     _celebration_sys = cpputils::make_unique<CelebrationSystem>();
-    _wall_labels     = WallLabel::generate_All(World::get_BoxScale());
-    _word_ai_sys     = cpputils::make_unique<WordAiSystem>();
 
     _floor_sys       = cpputils::make_unique<FloorMandelbrot>  ();
     _floor_sys->generate(World::get_BoxScale(), World::get_FloorY());
 
+    _wall_labels     = WallLabel::generate_All(World::get_BoxScale());
+
+    _word_ai_sys     = cpputils::make_unique<WordAiSystem>();
+
     _pult_sys        = cpputils::make_unique<PultSystem>  ();
     _pult_sys->init(_ctx.scenery, _ctx.buttons, World::get_BoxScale());
 
     if (starting_level >= Level::level_Count()) starting_level = 0;
-    _level_runner_sys = cpputils::make_unique<LevelRunner>(Level::get_Level(starting_level));
+    _level_director  = cpputils::make_unique<LevelDirector>(Level::get_Level(starting_level));
 
-    DynArray<std::string> tokens = _level_runner_sys->grants_for_step();
-    step_Spawn(tokens);
+    _level_director->enter_Step(_ctx);
+    _word_ai_sys->set_MaxActiveAi(_level_director->get_MaxAiForStep());
 
-    scene_Setup(_level_runner_sys->current().scene);
-    step_SettingsApply();
-    infowall_StepdetailsShow();
+    _hint_sys = cpputils::make_unique<HintSystem>();
+    if (!_level_director->complete())
+        _hint_sys->set_Hint(_level_director->current_hint());
 
-    _hint_sys        = cpputils::make_unique<HintSystem>();
-    if (_level_runner_sys && !_level_runner_sys->complete())
-        _hint_sys->set_Hint(_level_runner_sys->current().hint);
-
-    if (_level_runner_sys && _level_runner_sys->dynamic_lib())
+    if (_level_director->runner().dynamic_lib())
         sandbox_LoadFileToWall();
 
     /*
@@ -87,12 +85,27 @@ GameState::GameState(size_t starting_level)
 
 
 void
-GameState::debug_SpawnWords([[maybe_unused]]size_t n)
+GameState::debug_SpawnWords([[maybe_unused]] size_t n)
 {
     DynArray<std::string> tokens = TokenDB::get_TestArrayAllKeywords();
-    step_Spawn(tokens);
-}
 
+    static constexpr float spacing = 3.0f;
+    static constexpr int   per_row = 8;
+    static constexpr float row_gap = 3.0f;
+    const double hover_y = World::get_FloorY<double>() + 1.5;
+
+    for (size_t i = 0; i < tokens.size(); ++i) {
+        Vec3 pos {
+            -static_cast<float>(per_row) * spacing * 0.5f
+                + static_cast<float>(i % per_row) * spacing,
+            hover_y,
+            static_cast<float>(i / per_row) * row_gap
+        };
+        Text3dPtr obj = make_Text3d(tokens[i], Text3d::depth_floor, 0.003f, pos);
+        obj->body.hover_height = hover_y;
+        _ctx.words.push_back(std::move(obj));
+    }
+}
 
 
 void
@@ -140,7 +153,7 @@ GameState::carriedline_Update()
 void
 GameState::pult_DispatchAction(PultAction act)
 {
-    _step_ledger.buttons_pressed.push_back(act);
+    _level_director->record_Button(act);
 
     switch (act) {
         case PultAction::Compile:          on_Compile();               break;
@@ -183,11 +196,11 @@ GameState::on_Pick()
             _editor_wall.on_WordRemoved(_ctx.wall_texts[r.wall_index]->text3d.get());
 
         Text3dPtr base = std::move(_ctx.wall_texts[r.wall_index]->text3d);
-        _ctx.wall_texts.erase(_ctx.wall_texts.begin() + static_cast<std::ptrdiff_t>(r.wall_index));
+        _ctx.remove_WallText(r.wall_index);
         if (!base) return false;
 
         if (base->type == ObjectType::Text) {
-            _step_ledger.picked_up.push_back(base->text);
+            _level_director->record_PickedUp(base->text);
             base->set_Look(ConfigColor::keep, Text3d::depth_floor);
             TextWall::show_TokenInfo(_ctx.wall_texts, base->text);
         }
@@ -200,7 +213,7 @@ GameState::on_Pick()
         _ctx.words.erase(_ctx.words.begin() + static_cast<std::ptrdiff_t>(r.word_index));
         if (!word) return false;
 
-        _step_ledger.picked_up.push_back(word->text);
+        _level_director->record_PickedUp(word->text);
         word->set_Look(ConfigColor::keep, Text3d::depth_floor);
         TextWall::show_TokenInfo(_ctx.wall_texts, word->text);
 
@@ -231,9 +244,9 @@ GameState::on_DropLast()
     if (!_carried.empty())
         TextWall::show_TokenInfo(_ctx.wall_texts, _carried.last->word->text);
     else
-        infowall_StepdetailsShow();
+        _level_director->show_Details(_ctx);
 
-    _step_ledger.dropped = true;
+    _level_director->record_Dropped();
 }
 
 
@@ -260,9 +273,9 @@ GameState::on_ShootOne()
 
     if (!_carried.empty() && _carried.last && _carried.last->word)
         TextWall::show_TokenInfo(_ctx.wall_texts, _carried.last->word->text);
-    else infowall_StepdetailsShow();
+    else _level_director->show_Details(_ctx);
 
-    _step_ledger.shot = true;
+    _level_director->record_Shot();
     return true;
 }
 
@@ -290,17 +303,16 @@ GameState::on_ShootAll()
                                  _ctx.camera->fwd, _ctx.camera->pos, aim);
     }
 
-    infowall_StepdetailsShow();
-
-    _step_ledger.shot = true;
+    _level_director->show_Details(_ctx);
+    _level_director->record_Shot();
 }
 
 
 void GameState::on_Compile() {
     std::string src = TextWall::extract_Text(_ctx.wall_texts, Wall::Role::Editor);
 
-    if ( !_level_runner_sys->complete() &&
-          _level_runner_sys->dynamic_lib()) {
+    if ( !_level_director->complete() &&
+          _level_director->runner().dynamic_lib()) {
         const std::string path = sandbox_CanonicalPath();
         std::string stripped;
         for (char c : src) if (!std::isspace((unsigned char)c))
@@ -425,34 +437,6 @@ GameState::projectile_FeedProgramInput(ProjectileResult& result)
 }
 
 
-void
-GameState::step_Spawn(const DynArray<std::string>& tokens)
-{
-    static constexpr float spacing = 3.0f;
-    static constexpr int   per_row = 8;
-    static constexpr float row_gap = 3.0f;
-
-    const double floor_y = World::get_FloorY<double>();
-    const double hover_y = floor_y + 1.5;
-
-    for (size_t i = 0; i < tokens.size(); ++i) {
-        size_t col = i % per_row;
-        size_t row = i / per_row;
-
-        Vec3 pos {
-            -static_cast<float>(per_row) * spacing * 0.5f
-                + static_cast<float>(col) * spacing,
-            hover_y,
-            static_cast<float>(row) * row_gap
-        };
-
-        Text3dPtr obj = make_Text3d(tokens[i], Text3d::depth_floor, 0.003f, pos);
-        obj->body.hover_height = hover_y;
-        _ctx.words.push_back(std::move(obj));
-    }
-}
-
-
 void
 GameState::projectile_LandOnFloor(ProjectileResult& result)
 {
@@ -534,24 +518,16 @@ GameState::walltext_HandleShootDown(ProjectileResult& result,
             _editor_wall.on_WordRemoved(wt->text3d.get());
 
             Text3dPtr orphan = std::move(wt->text3d);
-            orphan->body.mode         = PhysicsMode::Dynamic;
-            orphan->body.vel          = { 0.0, 1.5, 0.0 };
             orphan->body.hover_height = World::get_FloorY<double>() + 1.5;
             orphan->yaw               = 0.0f;
-            orphan->set_Look(ConfigColor::keep, Text3d::depth_floor);
-            _ctx.words.push_back(std::move(orphan));
+            _ctx.drop_WordToFloor(std::move(orphan), Vec3{0.0, 1.5, 0.0});
             to_erase.push_back(result.wall_text_index);
         }
     }
 
     for (Text3dPtr& w : result.words) {
         if (!w) continue;
-        if (w->type == ObjectType::Text) {
-            w->set_Look(ConfigColor::keep, Text3d::depth_floor);
-        }
-        w->body.mode = PhysicsMode::Dynamic;
-        w->body.vel  = result.vel;
-        _ctx.words.push_back(std::move(w));
+        _ctx.drop_WordToFloor(std::move(w), result.vel);
     }
 }
 
@@ -631,8 +607,6 @@ GameState::update(double dt)
 {
     FrameInput& input = _frame_input;
 
-    _step_age += dt;
-
     { FP_ZONE("update.input_dispatch");
         handle_Input(input, dt);
     }
@@ -660,7 +634,7 @@ GameState::update(double dt)
         _word_anim_sys->update(_anims_add, _anims_drop, _carried, _ctx.words, dt);
 
         _pult_sys->  update(dt, *_ctx.camera);
-        _plate_sys-> update(dt, *_ctx.player, _step_plates);
+        _plate_sys-> update(dt, *_ctx.player, _level_director->step_Plates());
         _editor_wall.update(dt, _ctx.camera->pos);
 
         _wall_connection.update(dt);
@@ -679,7 +653,7 @@ GameState::update(double dt)
         _compiler_sys->update(_ctx.wall_texts);
         if (_compiler_sys->last_compile_ok) {
             _compiler_sys->last_compile_ok = false;
-            _step_ledger.compile_ok = true;
+            _level_director->record_CompileOk();
         }
 
         _dyn_loader_sys->update(_ctx.wall_texts);
@@ -696,7 +670,7 @@ GameState::update(double dt)
 
     { FP_ZONE("update.goals");
         _celebration_sys->tick(dt, *_particle_sys);
-        level_TryAdvance();
+        level_TryAdvance(dt);
     }
 
     input.look_x = 0;
@@ -794,134 +768,7 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
 void
 GameState::infowall_Clear()
 {
-    _ctx.wall_texts.erase(
-        std::remove_if(_ctx.wall_texts.begin(), _ctx.wall_texts.end(),
-            [](const TextWallPtr& wt) {
-                return wt->role == Wall::Role::Info;
-            }),
-        _ctx.wall_texts.end());
-}
-
-
-void
-GameState::step_SettingsApply()
-{
-    if (!_level_runner_sys || _level_runner_sys->complete()) {
-        _word_ai_sys->set_MaxActiveAi(0);
-        return;
-    }
-
-    const Level::Step& step = _level_runner_sys->current();
-    _word_ai_sys->set_MaxActiveAi(step.max_ai_words);
-}
-
-void
-GameState::infowall_StepdetailsShow()
-{
-    infowall_Clear();
-
-    if (!_level_runner_sys || _level_runner_sys->complete()) return;
-
-    const std::string& details = _level_runner_sys->current().details;
-    if (details.empty()) return;
-
-    Wall::Transform  wall_transform = Wall::get_Transform(Wall::Role::Info, World::get_BoxScale());
-
-    constexpr size_t MAX_INFO_LINE_CHARS = 92;
-    DynArray<std::string> lines = wrap_Text(details, MAX_INFO_LINE_CHARS);
-
-    const float bottom_y = World::get_FloorY() + 1.0f;
-    float y = bottom_y + static_cast<float>(lines.size() - 1) * ConfigWall::text_line_spacing;
-
-    for (const std::string& line : lines) {
-        Vec3 pos = wall_transform.position;
-        pos.y = y;
-        y -= ConfigWall::text_line_spacing;
-        if (line.empty()) continue;
-
-        Text3dPtr t = make_Text3d(line, Text3d::depth_wall, 0.0025f,
-                                  pos,  ConfigColor::white);
-        t->yaw = wall_transform.yaw;
-
-        _ctx.wall_texts.push_back(cpputils::make_unique<TextWall>(
-            TextWall{ wall_transform.wall_position, Wall::Role::Info, std::move(t) }));
-    }
-}
-
-
-void
-GameState::scene_Setup(const Scene& scene)
-{
-    for (const PlacedWord& pw : scene.words) {
-        Text3dPtr t = make_Text3d(pw.text, Text3d::depth_floor, 0.003f,
-                                  pw.pos, pw.color);
-        t->body.hover_height = pw.pos.y;
-        _ctx.words.push_back(std::move(t));
-    }
-
-    for (const PlacedPlate& pp : scene.plates) {
-        auto plate = cpputils::make_unique<Plate>(pp.pos);
-        Plate* raw = plate.get();
-        _step_plates .push_back({ raw, pp.tag });
-        _step_scenery.push_back(raw);
-        _ctx.scenery .push_back(std::move(plate));
-    }
-
-    for (const PlacedPlatform& pl : scene.platforms) {
-        auto box = cpputils::make_unique<SolidBox>(pl.pos, pl.half_extents,
-                                                   pl.fill_color, pl.wire_color);
-        GameObject* raw = box.get();
-        _step_scenery.push_back(raw);
-        _ctx.scenery .push_back(std::move(box));
-    }
-
-    for (const PlacedAi& a : scene.ai_seeds) {
-        Text3dPtr t = make_Text3d(a.text, Text3d::depth_floor, 0.003f,
-                                  a.pos, ConfigColor::floor);
-        t->body.hover_height = a.pos.y;
-        t->ai.aggressiveness = a.aggressiveness;
-
-        switch (a.initial_state) {
-            case Text3dAI::State::Attack:
-                t->ai.state  = Text3dAI::State::Attack;
-                t->body.mode = PhysicsMode::AiHover;
-                t->set_Color(ConfigColor::attack);
-                t->ai.state_timer = 0.0;
-                t->ai.phase_timer = 0.5;
-                break;
-            case Text3dAI::State::Flee:
-                t->ai.state  = Text3dAI::State::Flee;
-                t->body.mode = PhysicsMode::AiHover;
-                t->set_Color(ConfigColor::flee);
-                break;
-            case Text3dAI::State::Idle:
-            case Text3dAI::State::Stunned:
-                break;
-        }
-
-        _ctx.words.push_back(std::move(t));
-    }
-}
-
-
-void
-GameState::scene_Teardown()
-{
-    if (!_step_scenery.empty()) {
-        std::unordered_set<GameObject*> scenery_set(_step_scenery.begin(), _step_scenery.end());
-        _ctx.scenery.erase(
-            std::remove_if(_ctx.scenery.begin(), _ctx.scenery.end(),
-                [&scenery_set](const GameObjectPtr& sp) {
-                    return scenery_set.count(sp.get()) > 0;
-                }),
-            _ctx.scenery.end()
-        );
-    }
-
-    dyn_Clear();
-
-    _step_scenery.clear();
-    _step_plates .clear();
+    _ctx.clear_WallRole(Wall::Role::Info);
 }
 
 
@@ -1032,10 +879,8 @@ GameState::update_WorldSimulation(double dt)
         }
 
         std::sort(wall_text_to_erase.begin(), wall_text_to_erase.end(), std::greater<size_t>{});
-        for (size_t idx : wall_text_to_erase) {
-            if (idx < _ctx.wall_texts.size())
-                _ctx.wall_texts.erase(_ctx.wall_texts.begin() + static_cast<std::ptrdiff_t>(idx));
-        }
+        for (size_t idx : wall_text_to_erase)
+            _ctx.remove_WallText(idx);
 
         for (const ProjectileContactEvent& ev : projectile_contacts) {
             if (std::optional<PultAction> act =
@@ -1046,8 +891,7 @@ GameState::update_WorldSimulation(double dt)
 
             if (ev.other && ev.other->type == ObjectType::Text) {
                 Text3d* word = static_cast<Text3d*>(ev.other);
-                _step_ledger.ai_defeated.push_back(word->text);
-
+                _level_director->record_AiDefeated(word->text);
                 word->ai.state       = Text3dAI::State::Stunned;
                 word->ai.state_timer = 0.0;
                 word->body.vel       = Vec3{0, 0, 0};
@@ -1069,87 +913,32 @@ GameState::update_WorldSimulation(double dt)
 }
 
 
-GoalContext
-GameState::goal_ContextBuild() const
+void
+GameState::level_TryAdvance(double dt)
 {
-    GoalContext gctx;
-
-    for (const StepPlate& sp : _step_plates)
-        if (sp.plate && sp.plate->active)
-            gctx.active_plate_tags.push_back(sp.tag);
-
-    gctx.buttons_pressed_this_step   = _step_ledger.buttons_pressed;
-    gctx.dropped_this_step           = _step_ledger.dropped;
-    gctx.shot_this_step              = _step_ledger.shot;
-    gctx.compile_ok_this_step        = _step_ledger.compile_ok;
-    gctx.ai_words_defeated_this_step = _step_ledger.ai_defeated;
-    gctx.picked_up_this_step         = _step_ledger.picked_up;
-
-    if (gctx.compile_ok_this_step) {
-        std::string src = TextWall::extract_Text(_ctx.wall_texts,
-                                                 Wall::Role::Editor);
-        gctx.editor_normalized = LevelCheck::normalize(src);
-    }
+    LevelDirector::AdvanceResult res =
+        _level_director->try_Advance(_ctx, dt, *_celebration_sys, *_particle_sys);
 
-    return gctx;
-}
+    if (!res.stepped) return;
 
+    dyn_Clear();
 
-bool
-GameState::level_TryAdvance()
-{
-    if (_celebration_sys->active()) {
-        if (_celebration_sys->progress() >= 1.0) {
-            _celebration_sys->finish(*_particle_sys);
-            level_Advance();
-            return true;
-        }
-        return false;
+    if (res.reset_carry) {
+        _ctx.words  .clear();
+        _projectiles.clear();
+        _anims_add  .clear();
+        _anims_drop .clear();
+        _carried = CarriedLine();
     }
 
-    GoalContext gctx = goal_ContextBuild();
-    if (!goal_Satisfied(_level_runner_sys->current().goal, gctx))
-        return false;
-    if (_step_age < ConfigLevel::settle_time_for_step)
-        return false;
-    if (!_level_runner_sys || _level_runner_sys->complete())
-        return false;
-
-    double glyph_yaw = SDL_atan2(-_ctx.camera->fwd.x, -_ctx.camera->fwd.z);
-    _celebration_sys->start(*_ctx.camera, glyph_yaw);
-    return false;
-}
-
-
-void
-GameState::level_Advance()
-{
-    scene_Teardown();
-    _step_ledger.clear();
-
-    if (_level_runner_sys->advance()) {
-        if (_level_runner_sys->current().reset_carry) {
-            _ctx.words  .clear();
-            _projectiles.clear();
-            _anims_add  .clear();
-            _anims_drop .clear();
-            _carried = CarriedLine();
-        }
-
-        DynArray<std::string>
-            tokens = _level_runner_sys->grants_for_step();
-
-        step_Spawn(tokens);
-        scene_Setup(_level_runner_sys->current().scene);
-        step_SettingsApply();
-        infowall_StepdetailsShow();
-        _hint_sys->set_Hint(_level_runner_sys->current().hint);
-    }
-    else {
+    if (res.advanced) {
+        _level_director->enter_Step(_ctx);
+        _word_ai_sys->set_MaxActiveAi(_level_director->get_MaxAiForStep());
+        _hint_sys->set_Hint(_level_director->current_hint());
+    } else {
         _hint_sys->clear();
-        infowall_StepdetailsShow();
+        _level_director->show_Details(_ctx);
     }
-    _step_age = 0.0;
 }
 
 
@@ -1202,10 +991,13 @@ GameState::dyn_Apply(const DynArray<DynObject>& objs)
     }
 }
 
-std::string GameState::sandbox_CanonicalPath() const {
-    return _level_runner_sys ? _level_runner_sys->source_file() : std::string{};
+std::string
+GameState::sandbox_CanonicalPath() const {
+    return _level_director ? _level_director->runner().source_file() : std::string{};
 }
-std::string GameState::sandbox_BackupPath() const {
+
+std::string
+GameState::sandbox_BackupPath() const {
     std::string c = sandbox_CanonicalPath();
     return c.empty() ? c : (c + ".last_ok");
 }
@@ -1235,8 +1027,9 @@ GameState::sandbox_LoadFileToWall()
 }
 
 
-void GameState::sandbox_Restore() {
-    if (!_level_runner_sys || !_level_runner_sys->dynamic_lib()) return;
+void
+GameState::sandbox_Restore() {
+    if (!_level_director || !_level_director->runner().dynamic_lib()) return;
     if (SourceTokens::copy_File(sandbox_BackupPath(), sandbox_CanonicalPath()))
         sandbox_LoadFileToWall();
 }
@@ -1355,11 +1148,7 @@ void
 GameState::editor_Clear()
 {
     _editor_wall.clear();
-
-    _ctx.wall_texts.erase(
-        std::remove_if(_ctx.wall_texts.begin(), _ctx.wall_texts.end(),
-            [](const TextWallPtr& wt){ return wt->role == Wall::Role::Editor; }),
-        _ctx.wall_texts.end());
+    _ctx.clear_WallRole(Wall::Role::Editor);
 }
 
 
@@ -1403,9 +1192,7 @@ GameState::editor_PlaceLines(const DynArray<std::string>& lines)
             t->yaw       = tf.yaw;
             Vec3 snapped = _editor_wall.on_WordPlaced(t.get(), Vec3{ x_wall, y, z });
             t->pos       = snapped;
-
-            _ctx.wall_texts.push_back(cpputils::make_unique<TextWall>(TextWall{
-                Wall::Position::NegX, Wall::Role::Editor, std::move(t) }));
+            _ctx.add_WallText(Wall::Position::NegX, Wall::Role::Editor, std::move(t));
             z = snapped.z - 0.5;
         }
         y -= ConfigWall::line_height;
diff --git a/src/states/game_state.hpp b/src/states/game_state.hpp
index 93961c6..4553153 100644
--- a/src/states/game_state.hpp
+++ b/src/states/game_state.hpp
@@ -16,7 +16,7 @@
 #include "systems/freelook_system.hpp"
 #include "systems/hint_system.hpp"
 #include "systems/input_system.hpp"
-#include "systems/level_runner_system.hpp"
+#include "systems/level_director.hpp"
 #include "systems/particle_system.hpp"
 #include "systems/physics_system.hpp"
 #include "systems/plate_system.hpp"
@@ -34,43 +34,15 @@
 #include "wall/wall_label.hpp"
 
 
-struct StepLedger {
-    DynArray<PultAction>  buttons_pressed;
-    DynArray<std::string> ai_defeated;
-    DynArray<std::string> picked_up;
-    bool compile_ok {false};
-    bool dropped    {false};
-    bool shot       {false};
-
-    void clear() {
-        SDL_Log("LEDGER cleared (had %zu picked)", picked_up.size());
-        buttons_pressed.clear();
-        ai_defeated.clear();
-        picked_up.clear();
-        compile_ok = false;
-        dropped    = false;
-        shot       = false;
-    }
-};
-
-
-struct StepPlate {
-    Plate*      plate;
-    std::string tag;
-};
-
-
 class GameState : public IState
 {
     GameContext _ctx;
     FrameInput  _frame_input;
     CarriedLine _carried;
     EditorWall  _editor_wall;
-    StepLedger  _step_ledger;
 
     double _pick_cooldown  { 0.0 };
     double _shoot_cooldown { 0.0 };
-    double _step_age       { 0.0 };
 
 
     WallConnection _wall_connection;
@@ -97,8 +69,6 @@ class GameState : public IState
     DynArray<WordAnim_Drop> _anims_drop;
 
     DynArray<GameObject*>   _dyn_scenery;
-    DynArray<GameObject*>   _step_scenery;
-    DynArray<StepPlate>     _step_plates;
 
     cpputils::unique_ptr<InputSystem>      _input_sys;
     cpputils::unique_ptr<PhysicsSystem>    _physics_sys;
@@ -113,9 +83,9 @@ class GameState : public IState
     cpputils::unique_ptr<PlateSystem>      _plate_sys;
     cpputils::unique_ptr<ParticleSystem>   _particle_sys;
     cpputils::unique_ptr<FloorMandelbrot>  _floor_sys;
-    cpputils::unique_ptr<LevelRunner>      _level_runner_sys;
     cpputils::unique_ptr<DynLoaderSystem>  _dyn_loader_sys;
     cpputils::unique_ptr<CelebrationSystem>_celebration_sys;
+    cpputils::unique_ptr<LevelDirector>    _level_director;
 
 
     bool on_Pick         ();
@@ -134,25 +104,14 @@ class GameState : public IState
     void on_TypingCommit (FrameInput&);
     void on_TypingCancel (FrameInput&);
 
-    void scene_Setup       (const Scene& scene);
-    void scene_Teardown    ();
-    void update_WorldSimulation
-                           (double dt);
-    void step_Spawn        (const DynArray<std::string>& tokens);
-    void step_SettingsApply();
-
-    bool level_TryAdvance  ();
-    void level_Advance     ();
-    GoalContext
-         goal_ContextBuild () const;
-
+    void update_WorldSimulation(double dt);
+    void level_TryAdvance      (double dt);
     void carriedline_Update    ();
     void carry_BeginAnim       (Text3dPtr word, bool with_pitch);
     void typing_RebuildPreview (const std::string& s);
     void typing_ClearPreview   ();
 
     void infowall_Clear           ();
-    void infowall_StepdetailsShow ();
     void walltext_HandleShootDown (ProjectileResult& result, DynArray<size_t>& to_erase);
 
     void projectile_FeedProgramInput(ProjectileResult&);
diff --git a/src/systems/level_director.cpp b/src/systems/level_director.cpp
new file mode 100644
index 0000000..89b32b9
--- /dev/null
+++ b/src/systems/level_director.cpp
@@ -0,0 +1,227 @@
+#include "systems/level_director.hpp"
+
+#include <algorithm>
+#include <unordered_set>
+
+#include "entities/camera.hpp"
+#include "entities/plate.hpp"
+#include "entities/solid_box.hpp"
+#include "entities/world.hpp"
+#include "levels/level_check.hpp"
+#include "states/game_context.hpp"
+#include "systems/celebration_system.hpp"
+#include "systems/particle_system.hpp"
+#include "utils/config.hpp"
+#include "utils/text_wrap.hpp"
+#include "wall/text_wall.hpp"
+
+
+LevelDirector::LevelDirector(const Level::Data& level)
+    : _runner(cpputils::make_unique<LevelRunner>(level))
+{}
+
+
+int
+LevelDirector::get_MaxAiForStep() const
+{
+    if (_runner->complete()) return 0;
+    return _runner->current().max_ai_words;
+}
+
+
+void
+LevelDirector::enter_Step(GameContext& ctx)
+{
+    spawn_Grants(ctx);
+    setup_Scene(ctx, _runner->current().scene);
+    show_Details(ctx);
+    _step_age = 0.0;
+}
+
+
+void
+LevelDirector::spawn_Grants(GameContext& ctx)
+{
+    DynArray<std::string> tokens = _runner->grants_for_step();
+
+    static constexpr float spacing = 3.0f;
+    static constexpr int   per_row = 8;
+    static constexpr float row_gap = 3.0f;
+
+    const double hover_y = World::get_FloorY<double>() + 1.5;
+
+    for (size_t i = 0; i < tokens.size(); ++i) {
+        size_t col = i % per_row;
+        size_t row = i / per_row;
+
+        Vec3 pos {
+            -static_cast<float>(per_row) * spacing * 0.5f
+                + static_cast<float>(col) * spacing,
+            hover_y,
+            static_cast<float>(row) * row_gap
+        };
+
+        Text3dPtr obj = make_Text3d(tokens[i], Text3d::depth_floor, 0.003f, pos);
+        obj->body.hover_height = hover_y;
+        ctx.words.push_back(std::move(obj));
+    }
+}
+
+
+void
+LevelDirector::setup_Scene(GameContext& ctx, const Scene& scene)
+{
+    for (const PlacedWord& pw : scene.words) {
+        Text3dPtr t = make_Text3d(pw.text, Text3d::depth_floor, 0.003f, pw.pos, pw.color);
+        t->body.hover_height = pw.pos.y;
+        ctx.words.push_back(std::move(t));
+    }
+
+    for (const PlacedPlate& pp : scene.plates) {
+        auto plate = cpputils::make_unique<Plate>(pp.pos);
+        Plate* raw = plate.get();
+        _step_plates .push_back({ raw, pp.tag });
+        _step_scenery.push_back(raw);
+        ctx.scenery  .push_back(std::move(plate));
+    }
+
+    for (const PlacedPlatform& pl : scene.platforms) {
+        auto box = cpputils::make_unique<SolidBox>(pl.pos, pl.half_extents,
+                                                   pl.fill_color, pl.wire_color);
+        GameObject* raw = box.get();
+        _step_scenery.push_back(raw);
+        ctx.scenery  .push_back(std::move(box));
+    }
+
+    for (const PlacedAi& a : scene.ai_seeds) {
+        Text3dPtr t = make_Text3d(a.text, Text3d::depth_floor, 0.003f, a.pos, ConfigColor::floor);
+        t->body.hover_height = a.pos.y;
+        t->ai.aggressiveness = a.aggressiveness;
+
+        switch (a.initial_state) {
+            case Text3dAI::State::Attack:
+                t->ai.state  = Text3dAI::State::Attack;
+                t->body.mode = PhysicsMode::AiHover;
+                t->set_Color(ConfigColor::attack);
+                t->ai.state_timer = 0.0;
+                t->ai.phase_timer = 0.5;
+                break;
+            case Text3dAI::State::Flee:
+                t->ai.state  = Text3dAI::State::Flee;
+                t->body.mode = PhysicsMode::AiHover;
+                t->set_Color(ConfigColor::flee);
+                break;
+            case Text3dAI::State::Idle:
+            case Text3dAI::State::Stunned:
+                break;
+        }
+
+        ctx.words.push_back(std::move(t));
+    }
+}
+
+
+void
+LevelDirector::teardown_Scene(GameContext& ctx)
+{
+    if (!_step_scenery.empty()) {
+        std::unordered_set<GameObject*> scenery_set(_step_scenery.begin(), _step_scenery.end());
+        ctx.scenery.erase(
+            std::remove_if(ctx.scenery.begin(), ctx.scenery.end(),
+                [&scenery_set](const GameObjectPtr& sp) {
+                    return scenery_set.count(sp.get()) > 0;
+                }),
+            ctx.scenery.end());
+    }
+    _step_scenery.clear();
+    _step_plates .clear();
+}
+
+
+void
+LevelDirector::show_Details(GameContext& ctx) const
+{
+    ctx.clear_WallRole(Wall::Role::Info);
+
+    if (_runner->complete()) return;
+
+    const std::string& details = _runner->current().details;
+    if (details.empty()) return;
+
+    Wall::Transform wt = Wall::get_Transform(Wall::Role::Info, World::get_BoxScale());
+
+    constexpr size_t MAX_INFO_LINE_CHARS = 92;
+    DynArray<std::string> lines = wrap_Text(details, MAX_INFO_LINE_CHARS);
+
+    const float bottom_y = World::get_FloorY() + 1.0f;
+    float y = bottom_y + static_cast<float>(lines.size() - 1) * ConfigWall::text_line_spacing;
+
+    for (const std::string& line : lines) {
+        Vec3 pos = wt.position;
+        pos.y = y;
+        y -= ConfigWall::text_line_spacing;
+        if (line.empty()) continue;
+
+        Text3dPtr t = make_Text3d(line, Text3d::depth_wall, 0.0025f, pos, ConfigColor::white);
+        t->yaw = wt.yaw;
+        ctx.add_WallText(wt.wall_position, Wall::Role::Info, std::move(t));
+    }
+}
+
+
+GoalContext
+LevelDirector::build_GoalCtx(const GameContext& ctx) const
+{
+    GoalContext gctx;
+
+    for (const StepPlate& sp : _step_plates)
+        if (sp.plate && sp.plate->active)
+            gctx.active_plate_tags.push_back(sp.tag);
+
+    gctx.buttons_pressed_this_step   = _ledger.buttons_pressed;
+    gctx.dropped_this_step           = _ledger.dropped;
+    gctx.shot_this_step              = _ledger.shot;
+    gctx.compile_ok_this_step        = _ledger.compile_ok;
+    gctx.ai_words_defeated_this_step = _ledger.ai_defeated;
+    gctx.picked_up_this_step         = _ledger.picked_up;
+
+    if (gctx.compile_ok_this_step) {
+        std::string src = TextWall::extract_Text(ctx.wall_texts, Wall::Role::Editor);
+        gctx.editor_normalized = LevelCheck::normalize(src);
+    }
+    return gctx;
+}
+
+
+LevelDirector::AdvanceResult
+LevelDirector::try_Advance(GameContext& ctx, double dt,
+                           CelebrationSystem& celebration, ParticleSystem& particles)
+{
+    _step_age += dt;
+    AdvanceResult res;
+
+    if (celebration.active()) {
+        if (celebration.progress() >= 1.0) {
+            celebration.finish(particles);
+
+            teardown_Scene(ctx);
+            _ledger.clear();
+
+            res.stepped     = true;
+            res.advanced    = _runner->advance();
+            res.reset_carry = res.advanced && _runner->current().reset_carry;
+            _step_age       = 0.0;
+        }
+        return res;
+    }
+
+    if (_runner->complete()) return res;
+
+    GoalContext gctx = build_GoalCtx(ctx);
+    if (!goal_Satisfied(_runner->current().goal, gctx)) return res;
+    if (_step_age < ConfigLevel::settle_time_for_step)  return res;
+
+    double glyph_yaw = SDL_atan2(-ctx.camera->fwd.x, -ctx.camera->fwd.z);
+    celebration.start(*ctx.camera, glyph_yaw);
+    return res;
+}
diff --git a/src/systems/level_director.hpp b/src/systems/level_director.hpp
new file mode 100644
index 0000000..289d9c4
--- /dev/null
+++ b/src/systems/level_director.hpp
@@ -0,0 +1,89 @@
+#pragma once
+
+#include <string>
+
+#include "cpputils/unique_ptr.hpp"
+#include "entities/game_object.hpp"
+#include "levels/goal.hpp"
+#include "levels/level.hpp"
+#include "levels/scene.hpp"
+#include "systems/level_runner_system.hpp"
+#include "utils/globals.hpp"
+
+struct GameContext;
+class  Plate;
+class  WordAiSystem;
+class  HintSystem;
+class  CelebrationSystem;
+class  ParticleSystem;
+
+
+
+struct StepLedger {
+    DynArray<PultAction>  buttons_pressed;
+    DynArray<std::string> ai_defeated;
+    DynArray<std::string> picked_up;
+    bool compile_ok {false};
+    bool dropped    {false};
+    bool shot       {false};
+
+    void clear() {
+        buttons_pressed.clear();
+        ai_defeated.clear();
+        picked_up.clear();
+        compile_ok = false;
+        dropped    = false;
+        shot       = false;
+    }
+};
+
+
+struct StepPlate {
+    Plate*      plate;
+    std::string tag;
+};
+
+
+class LevelDirector
+{
+    cpputils::unique_ptr<LevelRunner> _runner;
+    StepLedger             _ledger;
+    DynArray<GameObject*>  _step_scenery;
+    DynArray<StepPlate>    _step_plates;
+    double                 _step_age { 0.0 };
+
+    void        spawn_Grants  (GameContext& ctx);
+    void        setup_Scene   (GameContext& ctx, const Scene& scene);
+    void        teardown_Scene(GameContext& ctx);
+    GoalContext build_GoalCtx (const GameContext& ctx) const;
+public:
+    struct AdvanceResult {
+        bool stepped     {false};   // a step boundary was crossed this frame
+        bool advanced    {false};   // ...and there is a real next step
+        bool reset_carry {false};   // next step requests carry/projectile wipe
+    };
+
+    explicit LevelDirector(const Level::Data& level);
+
+    void enter_Step(GameContext& ctx);
+
+    AdvanceResult try_Advance(GameContext& ctx, double dt,
+                              CelebrationSystem& celebration,
+                              ParticleSystem& particles);
+
+    void show_Details(GameContext& ctx) const;
+
+    void record_PickedUp  (const std::string& t) { _ledger.picked_up.push_back(t); }
+    void record_Shot      ()                     { _ledger.shot = true; }
+    void record_Dropped   ()                     { _ledger.dropped = true; }
+    void record_Button    (PultAction a)         { _ledger.buttons_pressed.push_back(a); }
+    void record_AiDefeated(const std::string& t) { _ledger.ai_defeated.push_back(t); }
+    void record_CompileOk ()                     { _ledger.compile_ok = true; }
+
+    LevelRunner&         runner()             { return *_runner; }
+    const LevelRunner&   runner()       const { return *_runner; }
+    bool                 complete()     const { return _runner->complete(); }
+    int                  get_MaxAiForStep() const;
+    const std::string&   current_hint() const { return _runner->current().hint; }
+    DynArray<StepPlate>& step_Plates()        { return _step_plates; }
+};