Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / 7692c36

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

Start implementing Connect pult With Claude

Christian Immanuel · 2026-06-05 20:13 · 7692c36925d25e096413bf7dfb309e5c3b4c1ed8

 src/states/game_state.cpp                          | 602 +++++++++++++++------
 src/states/game_state.hpp                          |  45 +-
 ...mod_loader_system.cpp => dyn_loader_system.cpp} |  49 +-
 ...mod_loader_system.hpp => dyn_loader_system.hpp} |  12 +-
 src/systems/game_mod_abi.h                         |  12 +-
 src/systems/input_system.cpp                       |  41 +-
 src/systems/input_system.hpp                       |  13 +-
 src/systems/physics_system.cpp                     |  17 +-
 src/systems/physics_system.hpp                     |   6 +-
 src/systems/projectile_system.cpp                  |   5 +-
 src/systems/projectile_system.hpp                  |   3 +-
 src/systems/pult_system.cpp                        |  10 +
 src/systems/pult_system.hpp                        |   2 +
 src/systems/word_anim.cpp                          |   5 +-
 src/systems/word_anim.hpp                          |   2 +-
 src/text_3d/vim_buffer.hpp                         | 174 ++++++
 src/utils/ascii.hpp                                |   2 +-
 src/wall/text_wall.cpp                             |  22 +-
 src/wall/text_wall.hpp                             |   5 +-
 19 files changed, 762 insertions(+), 265 deletions(-)

diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index fe7fd9d..0bb6d94 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -41,7 +41,7 @@ GameState::GameState(size_t starting_level)
     _word_anim_sys   = cpputils::make_unique<WordAnimSystem>   ();
     _freelook_sys    = cpputils::make_unique<FreelookSystem>   ();
     _particle_sys    = cpputils::make_unique<ParticleSystem>   ();
-    _mod_loader_sys  = cpputils::make_unique<ModLoader>        ();
+    _dyn_loader_sys  = cpputils::make_unique<DynLoaderSystem>  ();
     _celebration_sys = cpputils::make_unique<CelebrationSystem>();
 
     _floor_sys       = cpputils::make_unique<FloorMandelbrot>  ();
@@ -93,20 +93,11 @@ void
 GameState::debug_SpawnWords([[maybe_unused]]size_t n)
 {
     DynArray<std::string> tokens = TokenDB::get_TestArrayAllKeywords();
-    /*
-    DynArray<std::string> tokens;
-    tokens.reserve(n);
-    static const char* samples[] = {
-        "int", "for", "while", "return", "void", "char",
-        "if", "else", "(", ")", "{", "}", "==", "++"
-    };
-    for (size_t i = 0; i < n; ++i)
-        tokens.push_back(samples[i % (sizeof(samples)/sizeof(samples[0]))]);
-    */
     step_Spawn(tokens);
 }
 
 
+
 std::tuple<Vec3, Vec3, double>
 GameState::anchorframe_Current() const
 {
@@ -181,6 +172,12 @@ GameState::pult_DispatchAction(PultAction act)
     const double floor_y = World::get_FloorY<double>();
 
     switch (act) {
+        case PultAction::Compile:
+            on_Compile();
+            break;
+        case PultAction::RunProgram:
+            on_Run();
+            break;
         case PultAction::ScrollEditorUp:
             TextWallScroll::scroll_Up(_ctx.wall_texts, Wall::Role::Editor);
             _editor_wall.scroll(ConfigWall::scroll_step);
@@ -193,14 +190,8 @@ GameState::pult_DispatchAction(PultAction act)
             TextWallScroll::clear_ToFloor(_ctx.wall_texts, _ctx.words, Wall::Role::Editor, floor_y);
             _editor_wall.clear();
             break;
-        case PultAction::Compile:
-            on_Compile();
-            break;
-        case PultAction::RunProgram:
-            on_Run();
-            break;
+        case PultAction::ConnectEditor:  connect_BeginEditor(); break;
         case PultAction::ConnectProgram: connect_Begin(Conn::Program); break;
-        case PultAction::ConnectEditor:  break;
     }
 }
 
@@ -223,7 +214,6 @@ GameState::on_Pick()
     PickResult r = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery, _ctx.wall_texts);
     if (!r.hit()) return false;
 
-    // ── Wall text: pluck off the wall, animate into carry ───────────
     if (r.kind == PickKind::Wall) {
         if (_ctx.wall_texts[r.wall_index] && _ctx.wall_texts[r.wall_index]->text3d)
             _editor_wall.on_WordRemoved(_ctx.wall_texts[r.wall_index]->text3d.get());
@@ -235,13 +225,12 @@ GameState::on_Pick()
         if (base->type == ObjectType::Text) {
             _step_ledger.picked_up.push_back(base->text);
             base->set_Look(ConfigColor::keep, Text3d::depth_floor);
-            TextWall::show_TokenInfo(_ctx.wall_texts, base->text, World::get_BoxScale());
+            TextWall::show_TokenInfo(_ctx.wall_texts, base->text);
         }
-        carry_BeginAnim(std::move(base), /*with_pitch=*/true);
+        carry_BeginAnim(std::move(base), true);
         return true;
     }
 
-    // ── Word: same flow, takes Text3dPtr out of _ctx.words ───────────
     if (r.kind == PickKind::Word) {
         Text3dPtr word = std::move(_ctx.words[r.word_index]);
         _ctx.words.erase(_ctx.words.begin() + static_cast<std::ptrdiff_t>(r.word_index));
@@ -249,9 +238,9 @@ GameState::on_Pick()
 
         _step_ledger.picked_up.push_back(word->text);
         word->set_Look(ConfigColor::keep, Text3d::depth_floor);
-        TextWall::show_TokenInfo(_ctx.wall_texts, word->text, World::get_BoxScale());
+        TextWall::show_TokenInfo(_ctx.wall_texts, word->text);
 
-        carry_BeginAnim(std::move(word), /*with_pitch=*/false);
+        carry_BeginAnim(std::move(word), false);
         return true;
     }
     return false;
@@ -276,7 +265,7 @@ GameState::on_DropLast()
     _anims_drop.push_back(std::move(drop));
 
     if (!_carried.empty())
-        TextWall::show_TokenInfo(_ctx.wall_texts, _carried.last->word->text, World::get_BoxScale());
+        TextWall::show_TokenInfo(_ctx.wall_texts, _carried.last->word->text);
     else
         infowall_StepdetailsShow();
 
@@ -301,14 +290,12 @@ GameState::on_ShootOne()
     single.push_back(std::move(word));
 
     Aim::Result aim = Aim::crosshair_Target(*_ctx.camera, _ctx.words, _ctx.scenery,
-                                               _ctx.wall_texts, World::get_BoxScale());
-    _projectiles_sys->launch(_projectiles, std::move(single /* or all_words */),
+                                             _ctx.wall_texts, World::get_BoxScale());
+    _projectiles_sys->launch(_projectiles, std::move(single),
                              _ctx.camera->fwd, _ctx.camera->pos, aim);
 
     if (!_carried.empty() && _carried.last && _carried.last->word)
-        TextWall::show_TokenInfo(_ctx.wall_texts,
-                                 _carried.last->word->text,
-                                 World::get_BoxScale());
+        TextWall::show_TokenInfo(_ctx.wall_texts, _carried.last->word->text);
     else infowall_StepdetailsShow();
 
     _step_ledger.shot = true;
@@ -334,7 +321,7 @@ GameState::on_ShootAll()
     _carried = CarriedLine();
     if (!all_words.empty()) {
         Aim::Result aim = Aim::crosshair_Target(*_ctx.camera, _ctx.words, _ctx.scenery,
-                                                   _ctx.wall_texts, World::get_BoxScale());
+                                                _ctx.wall_texts, World::get_BoxScale());
         _projectiles_sys->launch(_projectiles, std::move(all_words),
                                  _ctx.camera->fwd, _ctx.camera->pos, aim);
     }
@@ -348,14 +335,15 @@ GameState::on_ShootAll()
 void GameState::on_Compile() {
     std::string src = TextWall::extract_Text(_ctx.wall_texts, Wall::Role::Editor);
 
-    if (_level_runner_sys && !_level_runner_sys->complete() && _level_runner_sys->dynamic_lib()) {
+    if ( !_level_runner_sys->complete() &&
+          _level_runner_sys->dynamic_lib()) {
         const std::string path = sandbox_CanonicalPath();
         std::string stripped;
         for (char c : src) if (!std::isspace((unsigned char)c)) { stripped = src; break; }
         if (!path.empty() && !stripped.empty())
             SourceTokens::write_File(path, src);
 
-        _mod_loader_sys->build(_ctx.wall_texts, src, World::get_BoxScale(), true);
+        _dyn_loader_sys->build(_ctx.wall_texts, src, true);
     } else {
         _compiler_sys->compile(_ctx.wall_texts, src);
     }
@@ -411,45 +399,45 @@ GameState::on_NumberAdjust(int delta)
     if (_carried.empty() || !_carried.last || !_carried.last->word) return;
     Text3d* w = _carried.last->word.get();
 
-    // Parse the text as an integer; bail if it isn't one.
     const std::string& s = w->text;
     if (s.empty()) return;
+
     size_t start = (s[0] == '-') ? 1 : 0;
     if (start == s.size()) return;
     for (size_t i = start; i < s.size(); ++i)
-        if (!std::isdigit(static_cast<unsigned char>(s[i]))) return;  // not a plain integer
+        if (!std::isdigit(static_cast<unsigned char>(s[i]))) return;
 
     long val = std::strtol(s.c_str(), nullptr, 10);
     val += delta;
 
     w->text = std::to_string(val);
-    w->rebuild_Mesh();   // regenerate glyphs + half_extent for the new text
+    w->rebuild_Mesh();
 }
 
 
 void
-GameState::on_TextStart(FrameInput& input)
+GameState::on_TypingStart(FrameInput& input)
 {
-    input.text_mode = true;
-    input.text_buffer.clear();
-    _typing_words    .clear();
-    _typing_last     .clear();
+    input.typing_mode = true;
+    input.typing_buffer.clear();
+    _typing_words.clear();
+    _typing_last .clear();
 }
 void
-GameState::on_TextCommit(FrameInput& input)
+GameState::on_TypingCommit(FrameInput& input)
 {
-    input.text_mode = false;
-    input.text_buffer.clear();
-    _typing_words    .clear();
-    _typing_last     .clear();
+    input.typing_mode = false;
+    input.typing_buffer.clear();
+    _typing_words.clear();
+    _typing_last .clear();
 }
 void
-GameState::on_TextCancel(FrameInput& input)
+GameState::on_TypingCancel(FrameInput& input)
 {
     typing_ClearPreview();
-    input.text_mode = false;
-    input.text_buffer.clear();
-    _typing_last     .clear();
+    input.typing_mode = false;
+    input.typing_buffer.clear();
+    _typing_last.clear();
 }
 
 
@@ -525,9 +513,9 @@ GameState::projectile_LandOnWall(ProjectileResult& result)
         w->body.mode = PhysicsMode::Static;
         w->body.vel  = Vec3{0, 0, 0};
 
-        TextWallPtr wt = TextWall::from_Drop(std::move(w), World::get_BoxScale());
+        TextWallPtr wt = TextWall::from_Drop(std::move(w));
         if (wt && wt->text3d && wt->role == Wall::Role::Editor)
-            editor_group.push_back(wt->text3d.get());     // collect, don't place yet
+            editor_group.push_back(wt->text3d.get());
         placed.push_back(std::move(wt));
     }
 
@@ -606,6 +594,57 @@ GameState::handle_Event(Game& game, const SDL_Event& event)
     _input_sys->handle_Event(game, event, _frame_input);
 }
 
+void
+GameState::handle_Input(FrameInput& input, double dt) {
+    for (InputCommand cmd : input.commands) {
+        switch (cmd) {
+            case InputCommand::Jump:          on_Jump();            break;
+            case InputCommand::Click:         on_Click();           break;
+            case InputCommand::Drop:          on_DropLast();        break;
+            case InputCommand::ShootAll:      on_ShootAll();        break;
+            case InputCommand::Compile:       on_Compile();         break;
+            case InputCommand::Run:           on_Run();             break;
+            case InputCommand::DebugPrint:    on_DebugPrint();      break;
+            case InputCommand::FreelookStart: on_FreelookStart();   break;
+            case InputCommand::FreelookEnd:   on_FreelookEnd();     break;
+            case InputCommand::NumberInc:     on_NumberAdjust(+1);  break;
+            case InputCommand::NumberDec:     on_NumberAdjust(-1);  break;
+            case InputCommand::TypingStart:   on_TypingStart (input); break;
+            case InputCommand::TypingCommit:  on_TypingCommit(input); break;
+            case InputCommand::TypingCancel:  on_TypingCancel(input); break;
+            case InputCommand::SandboxRestore:    sandbox_Restore();  break;
+            case InputCommand::ConnectPultSubmit: conn_Submit();    break;
+            case InputCommand::ConnectPultExit:   connect_End();    break;
+        }
+    }
+    if (input.capture_vim) {
+        editor_FeedVim(input.vim_keys);
+        input.vim_keys.clear();
+    } else if (input.capture_typing) {
+        if (input.typing_buffer != _conn_last) { conn_RebuildPreview(); _conn_last = input.typing_buffer; }
+    } else if (input.typing_mode) {
+        input.forward = input.backward = input.left = input.right = false;
+        if (input.typing_buffer != _typing_last) { typing_RebuildPreview(input.typing_buffer); _typing_last = input.typing_buffer; }
+    }
+    if (_pick_cooldown > 0.0) _pick_cooldown -= dt;
+    if (input.pick && _pick_cooldown <= 0.0)
+        { if (on_Pick()) _pick_cooldown = 0.15; }
+
+    if (_shoot_cooldown > 0.0) _shoot_cooldown -= dt;
+    if (input.shoot && _shoot_cooldown <= 0.0)
+        { if (on_ShootOne()) _shoot_cooldown = 0.15; }
+}
+
+void
+GameState::update_PlayerCamera(FrameInput& input, double dt)
+{
+    if (!_freelook_sys->state.active)
+        _ctx.player->add_Look(input.look_x, input.look_y);
+
+    _freelook_sys->update(dt);
+    _ctx.player->compute_Movement(input);
+    _ctx.camera->syncFromPlayer(*_ctx.player, 0.0);
+}
 
 void
 GameState::update(double dt)
@@ -615,75 +654,18 @@ GameState::update(double dt)
     _step_age += dt;
 
     { FP_ZONE("update.input_dispatch");
-        for (InputCommand cmd : input.commands) {
-            switch (cmd) {
-                case InputCommand::Jump:          on_Jump();            break;
-                case InputCommand::Click:         on_Click();           break;
-                case InputCommand::Drop:          on_DropLast();        break;
-                case InputCommand::ShootAll:      on_ShootAll();        break;
-                case InputCommand::Compile:       on_Compile();         break;
-                case InputCommand::Run:           on_Run();             break;
-                case InputCommand::DebugPrint:    on_DebugPrint();      break;
-                case InputCommand::FreelookStart: on_FreelookStart();   break;
-                case InputCommand::FreelookEnd:   on_FreelookEnd();     break;
-                case InputCommand::NumberInc:     on_NumberAdjust(+1);  break;
-                case InputCommand::NumberDec:     on_NumberAdjust(-1);  break;
-                case InputCommand::TextStart:     on_TextStart (input); break;
-                case InputCommand::TextCommit:    on_TextCommit(input); break;
-                case InputCommand::TextCancel:    on_TextCancel(input); break;
-                case InputCommand::SandboxRestore:sandbox_Restore();    break;
-                case InputCommand::ConnectPultSubmit: conn_Submit();    break;
-                case InputCommand::ConnectPultExit:   connect_End();    break;
-            }
-        }
-        if (input.capture_typing) {
-            // ctrl+WASD already set movement in InputSystem; don't clear it.
-            if (input.text_buffer != _conn_last) {
-                conn_RebuildPreview();
-                _conn_last = input.text_buffer;
-            }
-        } else if (input.text_mode) {
-            input.forward = input.backward = input.left = input.right = false;
-            if (input.text_buffer != _typing_last) {
-                typing_RebuildPreview(input.text_buffer);
-                _typing_last = input.text_buffer;
-            }
-        }
-        if (_pick_cooldown > 0.0) _pick_cooldown -= dt;
-        if (input.pick && _pick_cooldown <= 0.0)
-            { if (on_Pick()) _pick_cooldown = 0.15; }
-
-        if (_shoot_cooldown > 0.0) _shoot_cooldown -= dt;
-        if (input.shoot && _shoot_cooldown <= 0.0)
-            { if (on_ShootOne()) _shoot_cooldown = 0.15; }
+        handle_Input(input, dt);
     }
 
-
-    // Freelook freezes the view only while held. Once released the player
-    // keeps full camera control immediately and the carried line flies back
-    // on its own (see anchorframe_Current / FreelookSystem::update).
-    if (!_freelook_sys->state.active)
-        _ctx.player->add_Look(input.look_x, input.look_y);
-
-    _freelook_sys->update(dt);
-
-    _accumulator += dt;
-    _ctx.player->compute_Movement(input);
-
-    // why do i still have that MAX_SUBSTEPS and not whatever frames we get
-    int steps = 0;
-    while (_accumulator >= dt && steps < ConfigGame::max_substeps) {
-        FP_ZONE("update.fixed_substep");
-        substep(dt);
-        _accumulator -= dt;
-        ++steps;
+    { FP_ZONE("update.player_cam");
+        update_PlayerCamera(input, dt);
     }
 
-    _accumulator = SDL_min(_accumulator, dt * ConfigGame::max_substeps);
-    _alpha       = _accumulator / dt;
+    { FP_ZONE("update.world_simulation");
+        update_WorldSimulation(dt);
+    }
 
     { FP_ZONE("update.post_step");
-        _ctx.camera->syncFromPlayer(*_ctx.player, _alpha);
 
         for (TextWallPtr& wt : _ctx.wall_texts) {
             if (!wt || !wt->text3d) continue;
@@ -695,13 +677,10 @@ GameState::update(double dt)
 
         carriedline_Update();
 
-        const double floor_y = World::get_FloorY<double>();
-        _word_anim_sys->update(_anims_add, _anims_drop, _carried, _ctx.words, dt, floor_y);
+        _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);
-
         _editor_wall.update(dt, _ctx.camera->pos);
 
         if (_conn != Conn::None) _conn_blink += dt;
@@ -723,10 +702,10 @@ GameState::update(double dt)
             _step_ledger.compile_ok = true;
         }
 
-        _mod_loader_sys->update(_ctx.wall_texts, World::get_BoxScale());
-        if (_mod_loader_sys->last_build_ok) {
-            _mod_loader_sys->last_build_ok = false;
-            mod_Apply(_mod_loader_sys->load_Objects());
+        _dyn_loader_sys->update(_ctx.wall_texts);
+        if (_dyn_loader_sys->last_build_ok) {
+            _dyn_loader_sys->last_build_ok = false;
+            dyn_Apply(_dyn_loader_sys->load_Objects());
             SourceTokens::copy_File(sandbox_CanonicalPath(), sandbox_BackupPath());
         }
     }
@@ -765,7 +744,7 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
     { FP_ZONE("render.set_Light");   renderer.set_Light(*_ctx.light); }
 
     { FP_ZONE("render.world");        _ctx.world ->draw(renderer, *_ctx.camera, *_ctx.light); }
-    { FP_ZONE("render._editor_wall");  _editor_wall.draw(renderer, *_ctx.camera, *_ctx.light); }
+    { FP_ZONE("render._editor_wall"); _editor_wall.draw(renderer, *_ctx.camera, *_ctx.light); }
 
     { FP_ZONE("render.scenery");
         for (const GameObjectPtr& s : _ctx.scenery)
@@ -788,10 +767,16 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
     }
 
     { FP_ZONE("render.wall_edit");
-        if (_conn != Conn::None) {
+        if (_conn == Conn::Program) {
             if (_conn_input) _conn_input->draw(renderer, *_ctx.camera, *_ctx.light);
             conn_DrawCursor(renderer);
             conn_DrawElectric(renderer);
+        } else if (_conn == Conn::Editor) {
+            for (const Text3dPtr& t : _edit_render)   if (t) t->draw(renderer, *_ctx.camera, *_ctx.light);
+            for (const Text3dPtr& n : _edit_linenums) if (n) n->draw(renderer, *_ctx.camera, *_ctx.light);
+            if (_edit_cmdline) _edit_cmdline->draw(renderer, *_ctx.camera, *_ctx.light);
+            edit_DrawCursor(renderer);
+            conn_DrawElectric(renderer);
         }
     }
 
@@ -964,7 +949,7 @@ GameState::scene_Teardown()
         );
     }
 
-    mod_Clear();
+    dyn_Clear();
 
     _step_scenery.clear();
     _step_plates .clear();
@@ -972,7 +957,7 @@ GameState::scene_Teardown()
 
 
 void
-GameState::substep(double dt)
+GameState::update_WorldSimulation(double dt)
 {
     _ctx.player->prev_pos = _ctx.player->pos;
 
@@ -984,14 +969,12 @@ GameState::substep(double dt)
     }
 
     DynArray<GameObject*> colliders;
-    { FP_ZONE("subst.build_colliders");
-        colliders.reserve(_ctx.scenery.size() + _ctx.words.size());
-        for (const GameObjectPtr& p : _ctx.scenery) if (p) colliders.push_back(p.get());
-        for (const Text3dPtr&     p : _ctx.words)   if (p) colliders.push_back(p.get());
-    }
+    colliders.reserve(_ctx.scenery.size() + _ctx.words.size());
+    for (const GameObjectPtr& p : _ctx.scenery) if (p) colliders.push_back(p.get());
+    for (const Text3dPtr&     p : _ctx.words)   if (p) colliders.push_back(p.get());
 
     { FP_ZONE("subst.phys_objects");
-        _physics_sys->update_Objects(colliders, dt, 25.0, World::get_BoxScale());
+        _physics_sys->update_Objects(colliders, dt, 25.0);
     }
 
     { FP_ZONE("subst.word_ai");
@@ -1037,12 +1020,10 @@ GameState::substep(double dt)
                 });
         }
 
-        // Hit Line Number
         for (const EditorWall::NumberHit& nh : _editor_wall.number_Hitboxes())
             wall_text_hits.push_back({ Wall::Position::NegX,
                                        nh.aabb_min, nh.aabb_max, nh.index });
 
-        // Hit Wall Label
         for (size_t li = 0; li < _wall_labels.size(); ++li) {
             const DynArray<Text3dPtr>& words = _wall_labels[li].label.words();
             for (size_t wi = 0; wi < words.size(); ++wi) {
@@ -1061,7 +1042,7 @@ GameState::substep(double dt)
     DynArray<ProjectileResult> proj_results;
     { FP_ZONE("subst.projectile_sys");
         _projectiles_sys->update(_projectiles, proj_results,
-                                 wall_text_hits, dt, World::get_BoxScale(),
+                                 wall_text_hits, dt,
                                  _program_sys->is_Running());
     }
 
@@ -1112,7 +1093,6 @@ GameState::substep(double dt)
 
         _physics_sys->resolve_PlayerVsObjects(*_ctx.player, _carried, colliders);
 
-        // Jump-on-button
         if (std::optional<PultAction> act =
                 _pult_sys->handle_PlayerContact(*_ctx.player, _ctx.buttons); act)
             pult_DispatchAction(*act);
@@ -1202,11 +1182,11 @@ GameState::level_Advance()
 
 
 void
-GameState::mod_Clear()
+GameState::dyn_Clear()
 {
-    if (_mod_scenery.empty()) { return; }
+    if (_dyn_scenery.empty()) { return; }
 
-    std::unordered_set<GameObject*> set(_mod_scenery.begin(), _mod_scenery.end());
+    std::unordered_set<GameObject*> set(_dyn_scenery.begin(), _dyn_scenery.end());
     _ctx.scenery.erase(
         std::remove_if(_ctx.scenery.begin(), _ctx.scenery.end(),
             [&set](const GameObjectPtr& sp) { return set.count(sp.get()) > 0; }),
@@ -1215,31 +1195,31 @@ GameState::mod_Clear()
         std::remove_if(_ctx.words.begin(), _ctx.words.end(),
             [&set](const Text3dPtr& wp) { return set.count(wp.get()) > 0; }),
         _ctx.words.end());
-    _mod_scenery.clear();
+    _dyn_scenery.clear();
 }
 
 
 void
-GameState::mod_Apply(const DynArray<ModObject>& objs)
+GameState::dyn_Apply(const DynArray<DynObject>& objs)
 {
-    mod_Clear();
+    dyn_Clear();
 
     // Build fresh objects from the descriptions.
-    for (const ModObject& o : objs) {
-        if (o.kind == MOD_BOX) {
+    for (const DynObject& o : objs) {
+        if (o.kind == DYN_BOX) {
             auto box = cpputils::make_unique<SolidBox>(
                 Vec3{ o.x, o.y, o.z },
                 Vec3{ o.hx, o.hy, o.hz },
                 SDL_FColor{ o.r, o.g, o.b, 1.0f });
             box->body.mode = o.dynamic ? PhysicsMode::Dynamic : PhysicsMode::Static;
-            _mod_scenery.push_back(box.get());
+            _dyn_scenery.push_back(box.get());
             _ctx.scenery.push_back(std::move(box));
-        } else { // MOD_TEXT
+        } else { // DYN_TEXT
             Text3dPtr t = make_Text3d(o.text, Text3d::depth_floor, 0.003f,
                                       Vec3{ o.x, o.y, o.z },
                                       SDL_FColor{ o.r, o.g, o.b, 1.0f });
             t->body.mode = PhysicsMode::Static;   // lib text just sits where placed
-            _mod_scenery.push_back(t.get());
+            _dyn_scenery.push_back(t.get());
             _ctx.words.push_back(std::move(t));
         }
     }
@@ -1376,17 +1356,7 @@ GameState::connect_Begin(Conn c)
     _conn          = c;
     _conn_pult_pos = _pult_sys->get_ConnectProgramPos();
     _frame_input.capture_typing = true;
-    _frame_input.text_buffer.clear();
-    _conn_last.clear();
-    _conn_input.reset();
-}
-
-void
-GameState::connect_End()
-{
-    _conn = Conn::None;
-    _frame_input.capture_typing = false;
-    _frame_input.text_buffer.clear();
+    _frame_input.typing_buffer.clear();
     _conn_last.clear();
     _conn_input.reset();
 }
@@ -1394,8 +1364,8 @@ GameState::connect_End()
 void
 GameState::conn_Submit()
 {
-    const std::string line = _frame_input.text_buffer;
-    _frame_input.text_buffer.clear();
+    const std::string line = _frame_input.typing_buffer;
+    _frame_input.typing_buffer.clear();
     _conn_last.clear();
     _conn_input.reset();
 
@@ -1419,7 +1389,7 @@ GameState::conn_RebuildPreview()
     Vec3 pos = tf.position;
     pos.y = ProgramSystem::start_y;
 
-    _conn_input = make_Text3d("> " + _frame_input.text_buffer,
+    _conn_input = make_Text3d("> " + _frame_input.typing_buffer,
                               Text3d::depth_wall, 0.003f, pos, ConfigColor::white);
     _conn_input->yaw = tf.yaw;
 }
@@ -1428,10 +1398,10 @@ void
 GameState::conn_DrawCursor(GlRenderer& gl)
 {
     if (!_conn_input) return;
-    if (SDL_fmod(_conn_blink, 0.8) > 0.4) return;   // blink: on half the cycle
+    if (SDL_fmod(_conn_blink, 0.8) > 0.4) return;
 
     Vec3 c = _conn_input->pos;
-    c.z   = _conn_input->pos.z - (static_cast<double>(_conn_input->half_extent) + 0.15);  // just past the line (wall +X → text runs along z)
+    c.z   = _conn_input->pos.z - (static_cast<double>(_conn_input->half_extent) + 0.15);
 
     Mat4 view  = buildViewMat4(*_ctx.camera);
     Mat4 model = buildModelMat4(c, _conn_input->yaw);
@@ -1482,3 +1452,307 @@ GameState::conn_DrawElectric(GlRenderer& gl)
     gl.draw_Lines(pts.data(), static_cast<int>(pts.size()) / 3);
     gl.set_Wireframe(false);
 }
+
+
+void
+GameState::connect_BeginEditor()
+{
+    _conn          = Conn::Editor;
+    _conn_pult_pos = _pult_sys->get_ConnectEditorPos();
+    _frame_input.capture_vim = true;
+    _edit_status.clear();
+
+    editor_Absorb();                  // loads _vim, removes real wall words
+
+    if (_vim.is_Empty())
+        _vim.start_Insert();          // empty wall → fresh line, ready to type
+    // else: load() left cursor at row 0 / col 0 in Normal mode
+
+    _vim_seen_version = _vim.version();
+    editor_RebuildRender();
+}
+
+void
+GameState::editor_Absorb()
+{
+    // Pull existing editor source (lines joined by spaces, '\n' separated).
+    std::string src = TextWall::extract_Text(_ctx.wall_texts, Wall::Role::Editor);
+
+    DynArray<std::string> lines;
+    std::string cur;
+    for (char ch : src) {
+        if (ch == '\n') { lines.push_back(cur); cur.clear(); }
+        else            { cur.push_back(ch); }
+    }
+    if (!cur.empty()) lines.push_back(cur);
+
+    // VimBuffer wants std::vector; convert (DynArray ↔ vector).
+    std::vector<std::string> v(lines.begin(), lines.end());
+    _vim.load(v);
+
+    // Remove the real editor words — the buffer is the source of truth now.
+    _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());
+}
+
+
+void
+GameState::editor_RebuildRender()
+{
+    _edit_render.clear();
+    _edit_linenums.clear();
+
+    const float ws = World::get_BoxScale();
+    Wall::Transform tf = Wall::get_Transform(Wall::Role::Editor, ws);
+    const double lh    = ConfigWall::line_height;
+    const double eye_y = _ctx.camera->pos.y;
+    const int    R     = _vim.row();
+    const int    N     = (int)_vim.lines().size();
+    const double bottom_y = -static_cast<double>(ws) + 1.2;
+
+    const double anchor_cursor = eye_y + static_cast<double>(R) * lh;
+    const double anchor_bottom = bottom_y + static_cast<double>(N - 1) * lh;
+    _edit_row0_y = SDL_min(anchor_cursor, anchor_bottom);
+
+    const auto& lines = _vim.lines();
+
+    // ── Pass 1: build the renderables in a LOCAL left-aligned frame ──────────
+    // u = 0 at block's left edge, increasing rightward. We track each line's
+    // text width to find the block width (gutter + widest line).
+    struct Row { Text3dPtr num; Text3dPtr txt; double txt_w; };
+    DynArray<Row> rows;
+    rows.reserve(lines.size());
+
+    // Gutter width: built from the widest line-number ("N"), plus a gap.
+    // Build numbers first to measure.
+    double gutter_w = 0.0;
+    for (size_t i = 0; i < lines.size(); ++i) {
+        Text3dPtr num = make_Text3d(std::to_string(i + 1), Text3d::depth_wall, 0.0022f,
+                                    tf.position, ConfigColor::editor);
+        num->yaw = tf.yaw;
+        gutter_w = SDL_max(gutter_w, 2.0 * static_cast<double>(num->half_extent));
+        Row r; r.num = std::move(num); rows.push_back(std::move(r));
+    }
+    const double gutter_gap  = 0.6;                 // space between number and code
+    const double code_u0     = gutter_w + gutter_gap;   // code's left edge in u
+
+    double block_w = code_u0;   // at least the gutter; grows with widest line
+    for (size_t i = 0; i < lines.size(); ++i) {
+        const bool drawable = lines[i].find_first_not_of(' ') != std::string::npos;
+        if (drawable) {
+            Text3dPtr t = make_Text3d(lines[i], Text3d::depth_wall, 0.003f,
+                                      tf.position, ConfigColor::wall);
+            t->yaw = tf.yaw;
+            rows[i].txt_w = 2.0 * static_cast<double>(t->half_extent);
+            rows[i].txt   = std::move(t);
+        } else {
+            rows[i].txt_w = 0.0;
+        }
+        block_w = SDL_max(block_w, code_u0 + rows[i].txt_w);
+    }
+
+    // ── Pass 2: center the block on the wall (z = 0) and emit ────────────────
+    // Block spans u ∈ [0, block_w]; we want it centered, so its world left edge
+    // (largest z) is at z = block_w/2, and z = z_left - u.
+    const double z_block_left = block_w * 0.5;
+
+    for (size_t i = 0; i < rows.size(); ++i) {
+        const double row_y = _edit_row0_y - static_cast<double>(i) * lh;
+
+        // number: left-aligned at u = 0 → its left edge sits at z_block_left
+        if (rows[i].num) {
+            const double he = static_cast<double>(rows[i].num->half_extent);
+            rows[i].num->pos   = tf.position;
+            rows[i].num->pos.y = row_y;
+            rows[i].num->pos.z = z_block_left - he;     // left edge at u=0, center inward
+            _edit_linenums.push_back(std::move(rows[i].num));
+        }
+
+        // code: left edge at u = code_u0
+        if (rows[i].txt) {
+            const double he = static_cast<double>(rows[i].txt->half_extent);
+            rows[i].txt->pos   = tf.position;
+            rows[i].txt->pos.y = row_y;
+            rows[i].txt->pos.z = z_block_left - code_u0 - he;   // left-aligned within block
+            _edit_render.push_back(std::move(rows[i].txt));
+        } else {
+            _edit_render.push_back(Text3dPtr{});        // keep row index alignment
+        }
+    }
+
+    // stash for the cursor (which needs the code column's world left edge)
+    _edit_code_left_z = z_block_left - code_u0;
+
+    editor_RebuildCmdline();
+}
+
+
+void
+GameState::editor_RebuildCmdline()
+{
+    _edit_cmdline.reset();
+
+    std::string text;
+    if (_vim.mode() == VimBuffer::Mode::Command) text = _vim.cmdline();   // includes ':'
+    else if (!_edit_status.empty())              text = _edit_status[0];  // help/first status line
+    else if (!_vim.status().empty())             text = _vim.status();
+    else {
+        switch (_vim.mode()) {
+            case VimBuffer::Mode::Insert: text = "-- INSERT --"; break;
+            default:                      text = "-- NORMAL --"; break;
+        }
+    }
+    if (text.empty()) return;
+
+    const float ws = World::get_BoxScale();
+    Wall::Transform tf = Wall::get_Transform(Wall::Role::Editor, ws);
+
+    Vec3 pos = tf.position;
+    pos.x += 0.8;                              // pull toward room center (off the wall, in front)
+    pos.y  = -static_cast<double>(ws) + 0.8;   // fixed near the floor — always visible
+    pos.z  = static_cast<double>(ws) - 1.0;    // left-aligned with the gutter
+
+    _edit_cmdline = make_Text3d(text, Text3d::depth_wall, 0.0028f, pos, ConfigColor::white);
+    _edit_cmdline->yaw   = tf.yaw;
+    _edit_cmdline->pos.z = 0.0;
+}
+
+
+void
+GameState::editor_FeedVim(DynArray<int>& keys)
+{
+    for (int k : keys) {
+        if      (k == -1) _vim.key_enter();
+        else if (k == -2) _vim.key_escape();
+        else if (k == -3) _vim.key_backspace();
+        else if (k  >  0) _vim.key_char(static_cast<char>(k));
+    }
+
+    switch (_vim.take_Signal()) {
+        case VimBuffer::Signal::Exit:  editor_CommitToWall(); connect_End(); return;
+        case VimBuffer::Signal::Write: editor_CommitToWall(); editor_Absorb();
+                                       _vim_seen_version = _vim.version() - 1; break;
+        case VimBuffer::Signal::Help:
+            _edit_status.clear();
+            _edit_status.push_back("-- VIM HELP --");
+            _edit_status.push_back("i a A o O  insert    Esc normal    h j k l move");
+            _edit_status.push_back("0 $  line ends   gg G :N  jump line   x dd  delete");
+            _edit_status.push_back(":w write to wall   :exit leave   ctrl+WASD move");
+            break;
+        default: break;
+    }
+
+    if (_vim.version() != _vim_seen_version) {
+        _vim_seen_version = _vim.version();
+        editor_RebuildRender();
+    }
+}
+
+void
+GameState::editor_CommitToWall()
+{
+    // buffer lines → real editor-wall words (throwing/compile/scroll work again)
+    DynArray<std::string> lines(_vim.lines().begin(), _vim.lines().end());
+    editor_PlaceLines(lines);
+    _edit_render.clear();
+    _edit_status.clear();
+}
+
+void
+GameState::connect_End()   // editor-aware version (replaces the Step-1 one)
+{
+    if (_conn == Conn::Editor) {
+        _frame_input.capture_vim = false;
+        _frame_input.vim_keys.clear();
+        _edit_render.clear();
+        _edit_linenums.clear();
+        _edit_cmdline.reset();
+        _edit_status.clear();
+    } else {
+        _frame_input.capture_typing = false;
+        _frame_input.typing_buffer.clear();
+        _conn_last.clear();
+        _conn_input.reset();
+    }
+    _conn = Conn::None;
+}
+
+void
+GameState::edit_DrawCursor(GlRenderer& gl)
+{
+    if (_conn != Conn::Editor) return;
+    if (SDL_fmod(_conn_blink, 0.8) > 0.4) return;
+
+    const int row = _vim.row();
+    if (row < 0 || row >= (int)_vim.lines().size()) return;
+
+    const float ws      = World::get_BoxScale();
+    Wall::Transform tf  = Wall::get_Transform(Wall::Role::Editor, ws);
+    const double lh     = ConfigWall::line_height;
+
+    const std::string& s = _vim.lines()[(size_t)row];
+    const double row_y   = _edit_row0_y - static_cast<double>(row) * lh;   // same formula
+
+    double cw, line_x = tf.position.x, he_y = 0.2;
+    if (row < (int)_edit_render.size() && _edit_render[(size_t)row]) {
+        const Text3dPtr& line = _edit_render[(size_t)row];
+        const int N = s.empty() ? 1 : (int)s.size();
+        cw     = (2.0 * static_cast<double>(line->half_extent)) / static_cast<double>(N);
+        line_x = line->pos.x;
+        he_y   = static_cast<double>(line->half_extent_y);
+    } else {
+        cw = 0.18;   // empty line: nominal advance
+    }
+
+    const int    col = _vim.col();
+    // left edge of the code column (set in editor_RebuildRender after centering)
+    const double zL  = _edit_code_left_z;
+    const double cz  = zL - (static_cast<double>(col) + 0.5) * cw;
+
+    Vec3 c { line_x, row_y - (he_y + 0.06), cz };   // underline: just below the glyphs
+
+    Mat4 view  = buildViewMat4(*_ctx.camera);
+    Mat4 model = buildModelMat4(c, tf.yaw);
+    Mat4 mvp   = Mat4::mul(gl.projection(), Mat4::mul(view, model));
+
+    gl.set_Wireframe(false);
+    gl.set_MVP(model, mvp);
+    gl.set_NormalMat(model);
+    gl.set_Light(*_ctx.light);
+    gl.set_Color(0.6f, 1.0f, 1.0f);
+    gl.set_Emissive(0.6f, 1.0f, 1.0f, 0.9f);
+    gl.draw_Box(static_cast<float>(cw * 0.45), 0.015f, 0.03f);   // wide+thin = underline
+    gl.set_Emissive(0.0f, 0.0f, 0.0f, 0.0f);
+}
+
+
+void
+GameState::editor_PlaceLines(const DynArray<std::string>& lines)
+{
+    const float ws = World::get_BoxScale();
+    const double x_wall = -static_cast<double>(ws) + 0.05;
+    Wall::Transform tf = Wall::get_Transform(Wall::Role::Editor, ws);
+
+    double y = static_cast<double>(ws) - 2.0;
+    const double z_start = static_cast<double>(ws) - 1.0;
+
+    for (const std::string& line : lines) {
+        double z = z_start;
+        std::istringstream in(line);
+        std::string tk;
+        while (in >> tk) {
+            Text3dPtr t = make_Text3d(tk, Text3d::depth_wall, 0.003f,
+                                      Vec3{ x_wall, y, z }, ConfigColor::wall);
+            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) }));
+            z = snapped.z - 0.5;
+        }
+        y -= ConfigWall::line_height;
+    }
+}
diff --git a/src/states/game_state.hpp b/src/states/game_state.hpp
index 4a92833..9c7e67e 100644
--- a/src/states/game_state.hpp
+++ b/src/states/game_state.hpp
@@ -12,11 +12,11 @@
 #include "systems/carry_system.hpp"
 #include "systems/celebration_system.hpp"
 #include "systems/compiler_system.hpp"
+#include "systems/dyn_loader_system.hpp"
 #include "systems/freelook_system.hpp"
 #include "systems/hint_system.hpp"
 #include "systems/input_system.hpp"
 #include "systems/level_runner_system.hpp"
-#include "systems/mod_loader_system.hpp"
 #include "systems/particle_system.hpp"
 #include "systems/physics_system.hpp"
 #include "systems/plate_system.hpp"
@@ -25,6 +25,7 @@
 #include "systems/pult_system.hpp"
 #include "systems/word_ai_system.hpp"
 #include "systems/word_anim.hpp"
+#include "text_3d/vim_buffer.hpp"
 #include "utils/carry_math.hpp"
 #include "utils/globals.hpp"
 #include "wall/editor_wall.hpp"
@@ -69,12 +70,15 @@ class GameState : public IState
     double _pick_cooldown  { 0.0 };
     double _shoot_cooldown { 0.0 };
     double _step_age       { 0.0 };
-    double _accumulator    { 0.0 };
-    double _alpha          { 0.0 };
-
-
 
     // TODO START: OBJECT ORIENT ME 
+    VimBuffer             _vim;
+    unsigned              _vim_seen_version { 0 };
+    DynArray<Text3dPtr>   _edit_render;
+    DynArray<std::string> _edit_status;
+    double _edit_code_left_z { 0.0 };   // world z of the code column's left edge (for cursor)
+    double _edit_row0_y { 0.0 };   // world-y of buffer row 0 (scroll anchor)
+
     enum class Conn : uint8_t { None, Program, Editor };
     Conn        _conn        { Conn::None };
     std::string _conn_last;            // last previewed buffer
@@ -87,6 +91,16 @@ class GameState : public IState
     void conn_RebuildPreview ();
     void conn_DrawCursor     (GlRenderer& gl);
     void conn_DrawElectric   (GlRenderer& gl);
+    void connect_BeginEditor ();
+    void editor_Absorb       ();
+    void editor_RebuildRender();
+    void editor_CommitToWall ();
+    void editor_FeedVim      (DynArray<int>& keys);
+    void edit_DrawCursor     (GlRenderer& gl);
+    void editor_PlaceLines   (const DynArray<std::string>& lines);
+    void editor_RebuildCmdline();
+    DynArray<Text3dPtr> _edit_linenums;   // per-row "1,2,3…" labels (left gutter)
+    Text3dPtr           _edit_cmdline;    // pinned command/status bar (in front of wall)
     // TODO END: OBJECT ORIENT ME 
 
 
@@ -99,7 +113,7 @@ class GameState : public IState
     DynArray<WordAnim_Add>  _anims_add;
     DynArray<WordAnim_Drop> _anims_drop;
 
-    DynArray<GameObject*>   _mod_scenery;
+    DynArray<GameObject*>   _dyn_scenery;
     DynArray<GameObject*>   _step_scenery;
     DynArray<StepPlate>     _step_plates;
 
@@ -117,7 +131,7 @@ class GameState : public IState
     cpputils::unique_ptr<ParticleSystem>   _particle_sys;
     cpputils::unique_ptr<FloorMandelbrot>  _floor_sys;
     cpputils::unique_ptr<LevelRunner>      _level_runner_sys;
-    cpputils::unique_ptr<ModLoader>        _mod_loader_sys;
+    cpputils::unique_ptr<DynLoaderSystem>        _dyn_loader_sys;
     cpputils::unique_ptr<CelebrationSystem>_celebration_sys;
 
 
@@ -133,14 +147,14 @@ class GameState : public IState
     void on_Compile      ();
     void on_Run          ();
     void on_NumberAdjust (int dt);
-    void on_TextStart    (FrameInput&);
-    void on_TextCommit   (FrameInput&);
-    void on_TextCancel   (FrameInput&);
+    void on_TypingStart  (FrameInput&);
+    void on_TypingCommit (FrameInput&);
+    void on_TypingCancel (FrameInput&);
 
     void scene_Setup       (const Scene& scene);
     void scene_Teardown    ();
-    void substep           (double dt);
-
+    void update_WorldSimulation
+                           (double dt);
     void step_Spawn        (const DynArray<std::string>& tokens);
     void step_SettingsApply();
 
@@ -170,9 +184,12 @@ class GameState : public IState
     std::tuple<Vec3, Vec3, double>
          anchorframe_Current () const;
 
+    void handle_Input       (FrameInput&, double dt);
+    void update_PlayerCamera(FrameInput& input, double dt);
+
     // dynamic-lib sandbox START
-    void mod_Clear                   ();
-    void mod_Apply                   (const DynArray<ModObject>& objs);
+    void dyn_Clear                   ();
+    void dyn_Apply                   (const DynArray<DynObject>& objs);
     void sandbox_LoadFileToWall      ();
     void sandbox_Restore             ();
     std::string sandbox_CanonicalPath() const;
diff --git a/src/systems/mod_loader_system.cpp b/src/systems/dyn_loader_system.cpp
similarity index 82%
rename from src/systems/mod_loader_system.cpp
rename to src/systems/dyn_loader_system.cpp
index fcf7468..307175a 100644
--- a/src/systems/mod_loader_system.cpp
+++ b/src/systems/dyn_loader_system.cpp
@@ -4,7 +4,8 @@
 #include <string>
 #include <sys/select.h>
 
-#include "systems/mod_loader_system.hpp"
+#include "entities/world.hpp"
+#include "systems/dyn_loader_system.hpp"
 #include "text_3d/text_3d.hpp"
 #include "utils/ascii.hpp"
 
@@ -22,36 +23,36 @@ static constexpr const char* MOD_SRC_PATH    = "/tmp/game_mod.c";
 // the engine-provided output array. Bounds-checked so a player can't
 // overflow `out`.
 std::string
-ModLoader::wrap_Source(const std::string& user_body)
+DynLoaderSystem::wrap_Source(const std::string& user_body)
 {
     std::string s;
     s += "#include \"game_mod_abi.h\"\n";
     s += "#include <string.h>\n";
     s += "\n";
-    s += "static ModObject* g_out;\n";
+    s += "static DynObject* g_out;\n";
     s += "static int        g_max;\n";
     s += "static int        g_n;\n";
     s += "\n";
     s += "static void box(double x,double y,double z,"
          "double hx,double hy,double hz){\n";
     s += "    if(g_n>=g_max) return;\n";
-    s += "    ModObject* o=&g_out[g_n++];\n";
-    s += "    o->kind=MOD_BOX; o->x=x;o->y=y;o->z=z;\n";
+    s += "    DynObject* o=&g_out[g_n++];\n";
+    s += "    o->kind=DYN_BOX; o->x=x;o->y=y;o->z=z;\n";
     s += "    o->hx=hx;o->hy=hy;o->hz=hz;\n";
     s += "    o->r=0.55f;o->g=0.55f;o->b=0.75f; o->text[0]=0;\n";
     s += "}\n";
     s += "\n";
     s += "static void text(double x,double y,double z,const char* t){\n";
     s += "    if(g_n>=g_max) return;\n";
-    s += "    ModObject* o=&g_out[g_n++];\n";
-    s += "    o->kind=MOD_TEXT; o->x=x;o->y=y;o->z=z;\n";
+    s += "    DynObject* o=&g_out[g_n++];\n";
+    s += "    o->kind=DYN_TEXT; o->x=x;o->y=y;o->z=z;\n";
     s += "    o->hx=o->hy=o->hz=0.0;\n";
     s += "    o->r=1.0f;o->g=1.0f;o->b=1.0f;\n";
     s += "    strncpy(o->text, t?t:\"\", sizeof(o->text)-1);\n";
     s += "    o->text[sizeof(o->text)-1]=0;\n";
     s += "}\n";
     s += "\n";
-    s += "int game_build(ModObject* out,int max){\n";
+    s += "int game_build(DynObject* out,int max){\n";
     s += "    g_out=out; g_max=max; g_n=0;\n";
     s += "    /* ---- player source begins ---- */\n";
     s += user_body;
@@ -63,7 +64,7 @@ ModLoader::wrap_Source(const std::string& user_body)
 
 
 void
-ModLoader::build(TextWallList& texts, const std::string& user_src, float world_scale, bool full_source)
+DynLoaderSystem::build(TextWallList& texts, const std::string& user_src, bool full_source)
 {
     last_build_ok = false;
 
@@ -80,8 +81,8 @@ ModLoader::build(TextWallList& texts, const std::string& user_src, float world_s
     FILE* f = fopen(MOD_SRC_PATH, "w");
     if (!f) {
         SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
-                     "ModLoader: cannot write %s", MOD_SRC_PATH);
-        push_Line(texts, "internal error: cannot write source", world_scale);
+                     "DynLoaderSystem: cannot write %s", MOD_SRC_PATH);
+        push_Line(texts, "internal error: cannot write source");
         return;
     }
     const std::string out = full_source ? user_src : wrap_Source(user_src);
@@ -104,12 +105,12 @@ ModLoader::build(TextWallList& texts, const std::string& user_src, float world_s
     if (_pipe) { pclose(_pipe); _pipe = nullptr; }
     _pipe = popen(cmd.c_str(), "r");
     if (!_pipe)
-        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "ModLoader: popen failed");
+        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "DynLoaderSystem: popen failed");
 }
 
 
 void
-ModLoader::update(TextWallList& texts, float world_scale)
+DynLoaderSystem::update(TextWallList& texts)
 {
     if (!_pipe) return;
 
@@ -126,14 +127,14 @@ ModLoader::update(TextWallList& texts, float world_scale)
             if (_count == 0) {
                 last_build_ok = true;
                 _last_so_path = _pending_so;
-                push_Line(texts, "BUILD OK", world_scale);
+                push_Line(texts, "BUILD OK");
             }
             return;
         }
         std::string line(buf);
         if (!line.empty() && line.back() == '\n') line.pop_back();
         if (!line.empty())
-            push_Line(texts, line, world_scale);
+            push_Line(texts, line);
 
         FD_ZERO(&set); FD_SET(fd, &set);
     }
@@ -141,14 +142,14 @@ ModLoader::update(TextWallList& texts, float world_scale)
 
 
 void
-ModLoader::push_Line(TextWallList& texts, const std::string& line, float world_scale)
+DynLoaderSystem::push_Line(TextWallList& texts, const std::string& line)
 {
     // Shift existing compiler lines up, drop the new one at the bottom.
     for (TextWallPtr& wt : texts)
         if (wt->role == Wall::Role::Compiler)
             wt->text3d->pos.y += static_cast<double>(line_height);
 
-    Wall::Transform tf = Wall::get_Transform(Wall::Role::Compiler, world_scale);
+    Wall::Transform tf = Wall::get_Transform(Wall::Role::Compiler, World::get_BoxScale());
     Vec3 pos = tf.position;
     pos.y = start_y;
 
@@ -165,10 +166,10 @@ ModLoader::push_Line(TextWallList& texts, const std::string& line, float world_s
     _count++;
 }
 
-DynArray<ModObject>
-ModLoader::load_Objects()
+DynArray<DynObject>
+DynLoaderSystem::load_Objects()
 {
-    DynArray<ModObject> result;
+    DynArray<DynObject> result;
     if (_last_so_path.empty()) return result;
 
     // Close the previous handle before loading the new one. Safe because the
@@ -183,17 +184,17 @@ ModLoader::load_Objects()
     _handle = dlopen(_last_so_path.c_str(), RTLD_NOW | RTLD_LOCAL);
     if (!_handle) {
         SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
-                     "ModLoader: dlopen failed: %s", dlerror());
+                     "DynLoaderSystem: dlopen failed: %s", dlerror());
         return result;
     }
 
-    using BuildFn = int(*)(ModObject*, int);
+    using BuildFn = int(*)(DynObject*, int);
     // The cast-through-void* dance silences -Wpedantic on function-pointer casts.
     BuildFn game_build =
         reinterpret_cast<BuildFn>(dlsym(_handle, "game_build"));
     if (!game_build) {
         SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
-                     "ModLoader: dlsym(game_build) failed: %s", dlerror());
+                     "DynLoaderSystem: dlsym(game_build) failed: %s", dlerror());
         return result;
     }
 
@@ -207,7 +208,7 @@ ModLoader::load_Objects()
 }
 
 
-ModLoader::~ModLoader()
+DynLoaderSystem::~DynLoaderSystem()
 {
     if (_handle) dlclose(_handle);
     if (_pipe)   pclose(_pipe);
diff --git a/src/systems/mod_loader_system.hpp b/src/systems/dyn_loader_system.hpp
similarity index 82%
rename from src/systems/mod_loader_system.hpp
rename to src/systems/dyn_loader_system.hpp
index 5e19860..47ddd95 100644
--- a/src/systems/mod_loader_system.hpp
+++ b/src/systems/dyn_loader_system.hpp
@@ -7,7 +7,7 @@
 #include "utils/globals.hpp"
 #include "wall/text_wall.hpp"
 
-class ModLoader
+class DynLoaderSystem
 {
     FILE*       _pipe  { nullptr };
     int         _count { 0 };
@@ -22,21 +22,21 @@ class ModLoader
 
     static std::string
          wrap_Source(const std::string& user_body);
-    void push_Line  (TextWallList& texts, const std::string& line, float world_scale);
+    void push_Line  (TextWallList& texts, const std::string& line);
 
 public:
     bool last_build_ok { false };
 
-    void build       (TextWallList& texts, const std::string& user_src, float world_scale, bool full_source);
-    void update      (TextWallList& texts, float world_scale);
+    void build       (TextWallList& texts, const std::string& user_src, bool full_source);
+    void update      (TextWallList& texts);
     bool is_Running  () const { return _pipe != nullptr; }
     const std::string&
          last_so_path() const { return _last_so_path; }
 
     // Checkpoint 2: dlopen the last successful .so, call game_build,
     // return the spawned descriptions. Empty vector on any failure.
-    DynArray<ModObject>
+    DynArray<DynObject>
          load_Objects();
 
-    ~ModLoader();   // dlclose on teardown
+    ~DynLoaderSystem();   // dlclose on teardown
 };
diff --git a/src/systems/game_mod_abi.h b/src/systems/game_mod_abi.h
index 8ee2c8d..3f5be47 100644
--- a/src/systems/game_mod_abi.h
+++ b/src/systems/game_mod_abi.h
@@ -20,26 +20,26 @@ extern "C" {
 #endif
 
 typedef enum {
-    MOD_TEXT = 0,
-    MOD_BOX  = 1
-} ModObjKind;
+    DYN_TEXT = 0,
+    DYN_BOX  = 1
+} DynObjKind;
 
 /* A flat, POD description of one object to spawn. No pointers, no C++:
  * the library fills these in, the engine constructs the real Text3d /
  * SolidBox from them. Nothing the library allocates outlives dlclose(). */
 typedef struct {
-    int    kind;          /* ModObjKind */
+    int    kind;          /* DynObjKind */
     int    dynamic;
     double x, y, z;       /* world position (centre) */
     double hx, hy, hz;    /* half-extents — BOX only; ignored for TEXT */
     float  r, g, b;       /* colour 0..1 */
     char   text[64];      /* TEXT only, null-terminated; ignored for BOX */
-} ModObject;
+} DynObject;
 
 /* The user's compiled library MUST export this symbol.
  * Fills `out` (capacity `max` entries) and returns the number written.
  * Returning more than `max` is undefined; the engine passes a generous max. */
-int game_build(ModObject* out, int max);
+int game_build(DynObject* out, int max);
 
 #ifdef __cplusplus
 }
diff --git a/src/systems/input_system.cpp b/src/systems/input_system.cpp
index 0d53365..1c389b4 100644
--- a/src/systems/input_system.cpp
+++ b/src/systems/input_system.cpp
@@ -29,6 +29,20 @@ InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
         case SDL_EVENT_KEY_DOWN: {
             SDL_Keycode sym = e.key.key;
 
+            if (input.capture_vim) {
+                const bool ctrl = (e.key.mod & SDL_KMOD_CTRL);
+                if (ctrl) {
+                    switch (sym) {
+                        case SDLK_W: input.forward  = true; break;
+                        case SDLK_A: input.left     = true; break;
+                        case SDLK_S: input.backward = true; break;
+                        case SDLK_D: input.right    = true; break;
+                    }
+                } else if (sym == SDLK_RETURN)    input.vim_keys.push_back(-1);
+                else if   (sym == SDLK_ESCAPE)    input.vim_keys.push_back(-2);
+                else if   (sym == SDLK_BACKSPACE) input.vim_keys.push_back(-3);
+                break;
+            }
             if (input.capture_typing) {
                 const bool ctrl = (e.key.mod & SDL_KMOD_CTRL);
                 if (ctrl) {
@@ -42,19 +56,19 @@ InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
                     input.commands.push_back(InputCommand::ConnectPultSubmit);
                 } else if (sym == SDLK_ESCAPE) {
                     input.commands.push_back(InputCommand::ConnectPultExit);
-                } else if (sym == SDLK_BACKSPACE && !input.text_buffer.empty()) {
-                    input.text_buffer.pop_back();
+                } else if (sym == SDLK_BACKSPACE && !input.typing_buffer.empty()) {
+                    input.typing_buffer.pop_back();
                 }
                 break;
             }
 
-            if (input.text_mode) {
+            if (input.typing_mode) {
                 if (sym == SDLK_RETURN)
-                    input.commands.push_back(InputCommand::TextCommit);
+                    input.commands.push_back(InputCommand::TypingCommit);
                 else if (sym == SDLK_ESCAPE)
-                    input.commands.push_back(InputCommand::TextCancel);
-                else if (sym == SDLK_BACKSPACE && !input.text_buffer.empty())
-                    input.text_buffer.pop_back();
+                    input.commands.push_back(InputCommand::TypingCancel);
+                else if (sym == SDLK_BACKSPACE && !input.typing_buffer.empty())
+                    input.typing_buffer.pop_back();
                 break;   // swallow everything else — no movement while typing
             }
 
@@ -74,7 +88,7 @@ InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
                 case SDLK_C:      input.commands.push_back(InputCommand::Compile);       break;
                 case SDLK_R:      input.commands.push_back(InputCommand::Run);           break;
                 case SDLK_P:      input.commands.push_back(InputCommand::DebugPrint);    break;
-                case SDLK_T:      input.commands.push_back(InputCommand::TextStart);     break;
+                case SDLK_T:      input.commands.push_back(InputCommand::TypingStart);     break;
                 case SDLK_LSHIFT:
                 case SDLK_RSHIFT: input.commands.push_back(InputCommand::FreelookStart); break;
                 case SDLK_ESCAPE: game.change_State(cpputils::make_unique<MenuState>()); break;
@@ -103,15 +117,20 @@ InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
 
                 case SDLK_LSHIFT:
                 case SDLK_RSHIFT:
-                    if (!input.capture_typing) input.commands.push_back(InputCommand::FreelookEnd);
+                     if (!input.capture_typing && !input.capture_vim)
+                        input.commands.push_back(InputCommand::FreelookEnd);
                     break;
             }
             break;
         }
 
         case SDL_EVENT_TEXT_INPUT:
-            if (input.capture_typing || input.text_mode)
-                input.text_buffer += e.text.text;
+            if (input.capture_vim) {
+                for (const char* p = e.text.text; *p; ++p)
+                    input.vim_keys.push_back((unsigned char)*p);
+            } else if (input.capture_typing || input.typing_mode) {
+                input.typing_buffer += e.text.text;
+            }
             break;
     }
 }
diff --git a/src/systems/input_system.hpp b/src/systems/input_system.hpp
index c819ca5..ba551cd 100644
--- a/src/systems/input_system.hpp
+++ b/src/systems/input_system.hpp
@@ -17,7 +17,7 @@ enum class InputCommand : uint8_t {
     DebugPrint,
     FreelookStart, FreelookEnd,
     NumberInc, NumberDec,
-    TextStart, TextCommit, TextCancel,
+    TypingStart, TypingCommit, TypingCancel,
     SandboxRestore,
     ConnectPultSubmit,
     ConnectPultExit,
@@ -32,16 +32,17 @@ struct FrameInput {
     bool pick     = false;
     bool shoot    = false;
     bool capture_typing = false;
+    bool capture_vim = false;
 
+    bool        typing_mode   = false;
+    bool        typing_commit = false;
+    bool        typing_cancel = false;
 
     int  look_x   = 0;
     int  look_y   = 0;
 
-    bool        text_mode   = false;     // currently typing
-    std::string text_buffer;             // accumulated text
-    bool        text_commit = false;     // Enter pressed this frame
-    bool        text_cancel = false;     // Esc pressed this frame
-
+    std::string            typing_buffer;
+    DynArray<int>          vim_keys;
     DynArray<InputCommand> commands;
 };
 
diff --git a/src/systems/physics_system.cpp b/src/systems/physics_system.cpp
index 6457086..5f7d286 100644
--- a/src/systems/physics_system.cpp
+++ b/src/systems/physics_system.cpp
@@ -1,6 +1,7 @@
 #include <SDL3/SDL.h>
 #include <algorithm>
 
+#include "entities/world.hpp"
 #include "systems/collision.hpp"
 #include "systems/physics_system.hpp"
 #include "systems/projectile_system.hpp"
@@ -191,7 +192,7 @@ PhysicsSystem::try_Pair(GameObject* a, GameObject* b)
 
 void
 PhysicsSystem::update_Objects(const DynArray<GameObject*>& objects,
-                              double dt, double gravity, float world_scale)
+                              double dt, double gravity)
 {
     // Integration pass
     { FP_ZONE("phys.integrate");
@@ -222,7 +223,7 @@ PhysicsSystem::update_Objects(const DynArray<GameObject*>& objects,
     { FP_ZONE("phys.bounds");
         for (GameObject* obj : objects) {
             if (!obj || obj->body.mode != PhysicsMode::Dynamic) continue;
-            resolve_WorldBounds(*obj, world_scale);
+            resolve_WorldBounds(*obj);
         }
     }
 
@@ -232,7 +233,7 @@ PhysicsSystem::update_Objects(const DynArray<GameObject*>& objects,
     // which is the bulk of the old O(n²) work.
     { FP_ZONE("phys.pairs");
         // Build grid over ALL objects (passive included — movers must find them).
-        _grid.build(objects, static_cast<double>(world_scale));
+        _grid.build(objects);
 
         // Only active bodies initiate collision tests.
         DynArray<GameObject*> active;
@@ -250,9 +251,9 @@ PhysicsSystem::update_Objects(const DynArray<GameObject*>& objects,
 
 
 void
-PhysicsSystem::resolve_WorldBounds(GameObject& obj, float world_scale)
+PhysicsSystem::resolve_WorldBounds(GameObject& obj)
 {
-    const double ws = static_cast<double>(world_scale);
+    const double ws = World::get_BoxScale<double>();
     Vec3&        he = obj.shape.half_extents;
 
     // Floor
@@ -491,13 +492,13 @@ PhysicsSystem::resolve_Projectiles(DynArray<Projectile>& projectiles,
 
 
 void
-PhysicsSystem::SpatialGrid::build(const DynArray<GameObject*>& objs, double world_scale)
+PhysicsSystem::SpatialGrid::build(const DynArray<GameObject*>& objs)
 {
     // Cell size ~ a couple world units; tune if objects are much larger/smaller.
     cell = 2.0;
     inv  = 1.0 / cell;
-    min_x = min_z = -world_scale;
-    const double span = world_scale * 2.0;
+    min_x = min_z = -World::get_BoxScale<double>();
+    const double span = World::get_BoxScale<double>() * 2.0;
     nx = nz = static_cast<int>(span * inv) + 1;
 
     const int total = nx * nz;
diff --git a/src/systems/physics_system.hpp b/src/systems/physics_system.hpp
index 5ec9b97..8bd2c07 100644
--- a/src/systems/physics_system.hpp
+++ b/src/systems/physics_system.hpp
@@ -25,14 +25,14 @@ class PhysicsSystem {
         double min_x = 0, min_z = 0;
         DynArray<DynArray<GameObject*>> cells;
 
-        void build(const DynArray<GameObject*>& objs, double world_scale);
+        void build(const DynArray<GameObject*>& objs);
         int  index(int cx, int cz) const { return cz * nx + cx; }
     };
 
     SpatialGrid _grid;
 
     static void
-         resolve_WorldBounds(GameObject& obj, float world_scale);
+         resolve_WorldBounds(GameObject& obj);
     bool resolve_SingleBoxCollision(Projectile& proj,
                                     const Vec3& box_min, const Vec3& box_max,
                                     const Vec3& word_he, double restitution,
@@ -44,7 +44,7 @@ class PhysicsSystem {
 public:
     void update_Player(Player& player, double acc_x, double acc_z, double dt);
     void update_Objects(const DynArray<GameObject*>& objects,
-                        double dt, double gravity, float world_scale);
+                        double dt, double gravity);
 
     void update_Projectiles(DynArray<Projectile>& projectiles, double dt,
                             const DynArray<Text3dPtr>& words);
diff --git a/src/systems/projectile_system.cpp b/src/systems/projectile_system.cpp
index b753f10..fb73e57 100644
--- a/src/systems/projectile_system.cpp
+++ b/src/systems/projectile_system.cpp
@@ -459,10 +459,9 @@ void
 ProjectileSystem::update(DynArray<Projectile>&        projectiles,
                          DynArray<ProjectileResult>&  results,
                          const DynArray<WallTextHitTest>& wall_text_hits,
-                         double dt, float world_scale,
-                         bool program_running)
+                         double dt, bool program_running)
 {
-    const double ws = static_cast<double>(world_scale);
+    const double ws = World::get_BoxScale<double>();
 
     // ── 1. Advance stick animations ──────────────────────────────────────────
     for (Projectile& p : projectiles)
diff --git a/src/systems/projectile_system.hpp b/src/systems/projectile_system.hpp
index 473af94..45963f1 100644
--- a/src/systems/projectile_system.hpp
+++ b/src/systems/projectile_system.hpp
@@ -74,6 +74,5 @@ public:
     void update(DynArray<Projectile>&        projectiles,
                 DynArray<ProjectileResult>&  results,
                 const DynArray<WallTextHitTest>& wall_text_hits,
-                double dt, float world_scale,
-                bool program_running);
+                double dt, bool program_running);
 };
diff --git a/src/systems/pult_system.cpp b/src/systems/pult_system.cpp
index 7ab1f9a..f7041c9 100644
--- a/src/systems/pult_system.cpp
+++ b/src/systems/pult_system.cpp
@@ -75,6 +75,10 @@ PultSystem::init(DynArray<GameObjectPtr>& scenery,
     Pult* connp   = spawn_pult("Connect", Wall::Role::Program,  lane_3);
     add_btn(connp,   "CONN",  PultAction::ConnectProgram,   0.0f, 0.0f);
     _connect_program = connp;
+
+    Pult* conne  = spawn_pult("Edit",     Wall::Role::Editor,   lane_3);
+    add_btn(conne, "EDIT", PultAction::ConnectEditor,       0.0f, 0.0f);
+    _connect_editor = conne;
 }
 
 
@@ -182,3 +186,9 @@ PultSystem::get_ConnectProgramPos() const
 {
     return _connect_program ? _connect_program->pos : Vec3{};
 }
+
+Vec3
+PultSystem::get_ConnectEditorPos() const
+{
+    return _connect_editor ? _connect_editor->pos : Vec3{};
+}
diff --git a/src/systems/pult_system.hpp b/src/systems/pult_system.hpp
index a1a5819..c899c71 100644
--- a/src/systems/pult_system.hpp
+++ b/src/systems/pult_system.hpp
@@ -17,6 +17,7 @@ class PultSystem
 {
     DynArray<Pult*> _pults;
     Pult*           _connect_program { nullptr };
+    Pult*           _connect_editor  { nullptr };
 public:
     void init                    (DynArray<GameObjectPtr>& scenery,
                                   DynArray<PultButton*>&   buttons_out,
@@ -31,6 +32,7 @@ public:
          handle_PlayerContact    (const Player& player,
                                   const DynArray<PultButton*>& buttons);
     Vec3 get_ConnectProgramPos   () const;
+    Vec3 get_ConnectEditorPos    () const;
 
     DynArray<TextLabel*>
          get_Labels ();
diff --git a/src/systems/word_anim.cpp b/src/systems/word_anim.cpp
index a58d0d2..07ddd8f 100644
--- a/src/systems/word_anim.cpp
+++ b/src/systems/word_anim.cpp
@@ -1,5 +1,6 @@
 #include <algorithm>
 
+#include "entities/world.hpp"
 #include "word_anim.hpp"
 #include "utils/config.hpp"
 
@@ -18,9 +19,9 @@ WordAnimSystem::update(DynArray<WordAnim_Add>&    anims_add,
                        DynArray<WordAnim_Drop>&   anims_drop,
                        CarriedLine&                  carried,
                        DynArray<Text3dPtr>&       words,
-                       double                        dt,
-                       double                        floor_y)
+                       double                        dt)
 {
+    double floor_y = World::get_FloorY<double>();
     // Add animations
     for (WordAnim_Add& anim : anims_add) {
         if (anim.complete) continue;
diff --git a/src/systems/word_anim.hpp b/src/systems/word_anim.hpp
index ac53435..c3242b1 100644
--- a/src/systems/word_anim.hpp
+++ b/src/systems/word_anim.hpp
@@ -27,5 +27,5 @@ public:
                 DynArray<WordAnim_Drop>& anims_drop,
                 CarriedLine&             carried,
                 DynArray<Text3dPtr>&     words,
-                double dt, double floor_y);
+                double dt);
 };
diff --git a/src/text_3d/vim_buffer.hpp b/src/text_3d/vim_buffer.hpp
new file mode 100644
index 0000000..0b292c8
--- /dev/null
+++ b/src/text_3d/vim_buffer.hpp
@@ -0,0 +1,174 @@
+#pragma once
+// Dependency-free vim editing model: lines of text, a cursor, modes, and the
+// agreed motion/edit subset. No rendering, no game types — GameState drives it
+// and reads `lines()`/`row()`/`col()` to render. `version()` bumps on any change
+// so the caller knows when to re-render. Signals (Exit/Help/Write) surface
+// commands the host must act on.
+
+#include <string>
+#include <vector>
+#include <cctype>
+
+class VimBuffer {
+public:
+    enum class Mode   : uint8_t { Normal, Insert, Command };
+    enum class Signal : uint8_t { None, Exit, Help, Write };
+
+    void load(const std::vector<std::string>& src) {
+        _lines = src;
+        if (_lines.empty()) _lines.push_back("");
+        _row = _col = 0; _mode = Mode::Normal;
+        _count = 0; _pending.clear(); _cmd.clear(); _status.clear();
+        _sig = Signal::None; ++_version;
+    }
+
+    // ── feed (call in input order) ──────────────────────────────────────────
+    void key_char(char c) {
+        if      (_mode == Mode::Insert)  { insert_Char(c); }
+        else if (_mode == Mode::Command) { _cmd.push_back(c); touch(); }
+        else                             { normal_Key(c); }
+    }
+    void key_enter() {
+        if      (_mode == Mode::Insert)  { split_Line(); }
+        else if (_mode == Mode::Command) { run_Command(); }
+        else                             { _row = clampRow(_row + 1); clampCol(); touch(); }
+    }
+    void key_backspace() {
+        if      (_mode == Mode::Insert)  { backspace_Insert(); }
+        else if (_mode == Mode::Command) { if (_cmd.size() > 1) _cmd.pop_back(); else cancel_Command(); touch(); }
+        else                             { _col = (_col > 0 ? _col - 1 : 0); touch(); }
+    }
+    void key_escape() {
+        if (_mode == Mode::Command) { cancel_Command(); return; }
+        _mode = Mode::Normal;
+        if (_col > 0) --_col;
+        clampCol(); touch();
+    }
+
+    // ── host accessors ──────────────────────────────────────────────────────
+    const std::vector<std::string>& lines() const { return _lines; }
+    int          row()      const { return _row; }
+    int          col()      const { return _col; }
+    Mode         mode()     const { return _mode; }
+    unsigned     version()  const { return _version; }
+    bool         is_Empty() const { return _lines.size() == 1 && _lines[0].empty(); }
+    void         start_Insert()   { _mode = Mode::Insert; _col = 0; touch(); }
+    const std::string& cmdline() const { return _cmd; }     // includes leading ':'
+    const std::string& status()  const { return _status; }
+    Signal take_Signal() { Signal s = _sig; _sig = Signal::None; return s; }
+
+private:
+    std::vector<std::string> _lines { "" };
+    int      _row = 0, _col = 0;
+    Mode     _mode = Mode::Normal;
+    int      _count = 0;
+    std::string _pending;          // "d" or "g" awaiting second key
+    std::string _cmd;              // command-line text (":...")
+    std::string _status;
+    Signal   _sig = Signal::None;
+    unsigned _version = 0;
+
+    void touch() { ++_version; }
+    int  lineLen() const { return (int)_lines[(size_t)_row].size(); }
+    int  clampRow(int r) const { int n=(int)_lines.size(); return r<0?0:(r>=n?n-1:r); }
+    void clampCol() {
+        int maxc = (_mode==Mode::Insert) ? lineLen() : (lineLen()>0 ? lineLen()-1 : 0);
+        if (_col < 0) _col = 0;
+        if (_col > maxc) _col = maxc;
+    }
+    int  eff() const { return _count > 0 ? _count : 1; }
+
+    // ── Insert-mode edits ────────────────────────────────────────────────────
+    void insert_Char(char c) {
+        std::string& L = _lines[(size_t)_row];
+        if (_col > (int)L.size()) _col = (int)L.size();
+        L.insert((size_t)_col, 1, c); ++_col; touch();
+    }
+    void split_Line() {
+        std::string& L = _lines[(size_t)_row];
+        std::string tail = L.substr((size_t)_col);
+        L.erase((size_t)_col);
+        _lines.insert(_lines.begin() + _row + 1, tail);
+        ++_row; _col = 0; touch();
+    }
+    void backspace_Insert() {
+        if (_col > 0) { _lines[(size_t)_row].erase((size_t)(_col-1), 1); --_col; }
+        else if (_row > 0) {
+            int prevlen = (int)_lines[(size_t)(_row-1)].size();
+            _lines[(size_t)(_row-1)] += _lines[(size_t)_row];
+            _lines.erase(_lines.begin() + _row);
+            --_row; _col = prevlen;
+        }
+        touch();
+    }
+
+    // ── Normal-mode interpreter ───────────────────────────────────────────────
+    void normal_Key(char c) {
+        if (c == ':') { _mode = Mode::Command; _cmd = ":"; _status.clear(); touch(); return; }
+
+        if (std::isdigit((unsigned char)c)) {
+            if (c == '0' && _count == 0 && _pending.empty()) { _col = 0; touch(); return; } // 0 = motion
+            _count = _count * 10 + (c - '0'); return;
+        }
+
+        if (_pending == "d") { _pending.clear();
+            if (c == 'd') { delete_Lines(eff()); _count = 0; touch(); return; }
+            _count = 0; return;
+        }
+        if (_pending == "g") { _pending.clear();
+            if (c == 'g') { _row = clampRow(_count > 0 ? _count - 1 : 0); _count = 0; clampCol(); touch(); return; }
+            _count = 0; return;
+        }
+
+        switch (c) {
+            case 'h': _col -= eff(); break;
+            case 'l': _col += eff(); break;
+            case 'j': _row = clampRow(_row + eff()); break;
+            case 'k': _row = clampRow(_row - eff()); break;
+            case '0': _col = 0; break;
+            case '$': _col = lineLen() > 0 ? lineLen() - 1 : 0; break;
+            case 'x': delete_Chars(eff()); break;
+            case 'i': _mode = Mode::Insert; break;
+            case 'a': _col = (lineLen() > 0 ? _col + 1 : 0); _mode = Mode::Insert; break;
+            case 'A': _col = lineLen(); _mode = Mode::Insert; break;
+            case 'o': open_Below(); _mode = Mode::Insert; break;
+            case 'O': open_Above(); _mode = Mode::Insert; break;
+            case 'G': _row = clampRow(_count > 0 ? _count - 1 : (int)_lines.size() - 1); break;
+            case 'd': _pending = "d"; return;
+            case 'g': _pending = "g"; return;
+            default: break;
+        }
+        _count = 0; clampCol(); touch();
+    }
+
+    void delete_Chars(int n) {
+        std::string& L = _lines[(size_t)_row];
+        for (int i = 0; i < n && _col < (int)L.size(); ++i) L.erase((size_t)_col, 1);
+        clampCol();
+    }
+    void delete_Lines(int n) {
+        for (int i = 0; i < n && (int)_lines.size() > 0; ++i) {
+            if (_lines.size() == 1) { _lines[0].clear(); break; }
+            _lines.erase(_lines.begin() + _row);
+            if (_row >= (int)_lines.size()) _row = (int)_lines.size() - 1;
+        }
+        clampCol();
+    }
+    void open_Below() { _lines.insert(_lines.begin() + _row + 1, ""); ++_row; _col = 0; }
+    void open_Above() { _lines.insert(_lines.begin() + _row,     ""); _col = 0; }
+
+    // ── command line ──────────────────────────────────────────────────────────
+    void run_Command() {
+        std::string c = _cmd.size() ? _cmd.substr(1) : "";   // strip ':'
+        _cmd.clear(); _mode = Mode::Normal;
+        if      (c == "exit" || c == "q" || c == "q!") _sig = Signal::Exit;
+        else if (c == "help")                          _sig = Signal::Help;
+        else if (c == "w" || c == "write")             _sig = Signal::Write;
+        else if (!c.empty() && std::all_of(c.begin(), c.end(),
+                 [](char ch){ return std::isdigit((unsigned char)ch); })) {
+            _row = clampRow(std::stoi(c) - 1); clampCol();
+        } else _status = "not a command: " + c;
+        touch();
+    }
+    void cancel_Command() { _cmd.clear(); _mode = Mode::Normal; touch(); }
+};
diff --git a/src/utils/ascii.hpp b/src/utils/ascii.hpp
index bc5a66a..86c4bdc 100644
--- a/src/utils/ascii.hpp
+++ b/src/utils/ascii.hpp
@@ -7,7 +7,7 @@
 // gcc diagnostics use Unicode directional quotes (U+2018/2019 ‘ ’, U+201C/201D
 // “ ”) which the text renderer can't draw. This maps the common ones to plain
 // ' and ", and replaces any other multibyte sequence with '?'. Used by both
-// CompilerSystem and ModLoader so their wall output stays renderable.
+// CompilerSystem and DynLoaderSystem so their wall output stays renderable.
 //
 // Note: also set LC_ALL=C on the gcc invocation to make gcc emit ASCII quotes
 // at the source; this function is the belt-and-suspenders fallback.
diff --git a/src/wall/text_wall.cpp b/src/wall/text_wall.cpp
index 2131075..afbf9f7 100644
--- a/src/wall/text_wall.cpp
+++ b/src/wall/text_wall.cpp
@@ -1,24 +1,25 @@
+#include "entities/world.hpp"
 #include "text_wall.hpp"
 #include "utils/config.hpp"
 
 
 TextWallPtr
-TextWall::from_Drop(Text3dPtr src, float world_scale)
+TextWall::from_Drop(Text3dPtr src)
 {
-    float x = static_cast<float>(src->pos.x);
-    float z = static_cast<float>(src->pos.z);
+    double ws   = World::get_BoxScale<double>();
+    double x = src->pos.x;
+    double z = src->pos.z;
 
     Vec3& he = src->shape.half_extents;
 
-    float dPX = world_scale - x;
-    float dNX = world_scale + x;
-    float dPZ = world_scale - z;
-    float dNZ = world_scale + z;
+    double dPX = ws - x;
+    double dNX = ws + x;
+    double dPZ = ws - z;
+    double dNZ = ws + z;
 
     Wall::Position wall_pos;
     float yaw;
 
-    double ws = static_cast<double>(world_scale);
 
     // he.x is the text WIDTH half-extent, he.z is the text DEPTH (thickness).
     // After yaw rotation, the depth axis becomes the wall-normal axis.
@@ -128,8 +129,7 @@ TextWall::draw(GlRenderer& g, const Camera& c, const Light& l) const
 
 void
 TextWall::show_TokenInfo(TextWallList& texts,
-                           const std::string& token_text,
-                           float world_scale)
+                           const std::string& token_text)
 {
     const Token* tok = TokenDB::find_OrSynthesize(token_text);
 
@@ -141,7 +141,7 @@ TextWall::show_TokenInfo(TextWallList& texts,
 
     if (!tok) return;
 
-    Wall::Transform tf = Wall::get_Transform(Wall::Role::Info, world_scale);
+    Wall::Transform tf = Wall::get_Transform(Wall::Role::Info, World::get_BoxScale());
 
     // Line 1: token text + type
     std::string line1 = tok->text + "  [" + TokenDB::get_TypeName(tok->type) + "]";
diff --git a/src/wall/text_wall.hpp b/src/wall/text_wall.hpp
index 6aa0cf8..2f84c38 100644
--- a/src/wall/text_wall.hpp
+++ b/src/wall/text_wall.hpp
@@ -14,7 +14,7 @@ struct TextWall
     Text3dPtr      text3d;
 
     static TextWallPtr
-    from_Drop(Text3dPtr src, float world_scale);
+    from_Drop(Text3dPtr src);
 
     static std::string
     extract_Text(const TextWallList&, Wall::Role, double line_threshold = 0.3);
@@ -24,6 +24,5 @@ struct TextWall
 
     static void
     show_TokenInfo(TextWallList& texts,
-                   const std::string& token_text,
-                   float world_scale);
+                   const std::string& token_text);
 };