Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / 1b368fd

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

Some Cleanup on my own AND Start New Textstructure with Claude

Christian Immanuel · 2026-06-08 15:55 · 1b368fdb7ae28ac1aa1013ea5c09a220d87ce02f

 info.txt                                           |  73 ++
 src/core/game.cpp                                  |  44 +-
 src/core/game.hpp                                  |   4 +-
 src/core/main.cpp                                  |   5 +-
 src/entities/player.cpp                            |   3 +-
 src/entities/pult.cpp                              |  42 +-
 src/entities/world.cpp                             |  34 +-
 src/entities/world.hpp                             |   5 +
 .../level_director.cpp => levels/director.cpp}     |  31 +-
 .../level_director.hpp => levels/director.hpp}     |  24 +-
 .../progression.cpp}                               |  15 +-
 src/levels/progression.hpp                         |  33 +
 src/rendering/floor_mandelbrot.hpp                 |  25 -
 .../{floor_mandelbrot.cpp => floor_surface.cpp}    |  21 +-
 src/rendering/floor_surface.hpp                    |  44 +
 src/rendering/gl_renderer.cpp                      |  75 +-
 src/rendering/gl_renderer.hpp                      |   2 +
 .../surfaces.cpp => rendering/wall_surfaces.cpp}   |   3 +-
 src/states/game_state.cpp                          | 100 ++-
 src/states/game_state.hpp                          |  37 +-
 src/systems/carry_system.cpp                       |  74 +-
 src/systems/carry_system.hpp                       |   6 +
 src/systems/celebration_system.cpp                 |  14 +-
 src/systems/hint_system.cpp                        |  14 +-
 src/systems/input_system.cpp                       |  14 +-
 src/systems/level_runner_system.hpp                |  27 -
 src/systems/particle_system.cpp                    |  26 +-
 src/systems/physics_system.cpp                     |  99 +-
 src/systems/plate_system.cpp                       |  10 +-
 src/systems/plate_system.hpp                       |   6 +-
 src/systems/pult_system.cpp                        |  13 +-
 src/systems/wall_connection.cpp                    |  59 +-
 src/systems/wall_connection.hpp                    |  35 +-
 src/systems/word_ai_system.cpp                     |  18 +-
 src/systems/word_anim.cpp                          |  16 +-
 src/text/handler.cpp                               | 999 +++++++++++++++++++++
 src/text/handler.hpp                               | 206 +++++
 src/text/line.cpp                                  |  40 +
 src/text/line.hpp                                  |  55 ++
 src/text/token.cpp                                 | 377 ++++++++
 src/text/token.hpp                                 | 179 ++++
 src/text/vim_buffer.cpp                            | 265 ++++++
 src/text/vim_buffer.hpp                            |  82 ++
 src/text_3d/vim_buffer.cpp                         |  48 +-
 src/text_3d/vim_buffer.hpp                         |   2 +-
 src/utils/carry_math.cpp                           |  64 --
 src/utils/carry_math.hpp                           |  25 -
 src/utils/config.hpp                               |   9 +
 src/wall/editor.hpp                                |   4 +-
 src/wall/text.cpp                                  |  16 +-
 50 files changed, 2875 insertions(+), 547 deletions(-)

diff --git a/info.txt b/info.txt
index 8ce5b36..9d0f111 100644
--- a/info.txt
+++ b/info.txt
@@ -1,3 +1,76 @@
+
+
+Text::Handler
+    - handles tokens and lines as one commplete text structure so it can easily get tokenized from tree-sitter
+    - holds text like in a textedocument. so text has a width, height text-align line-height, word-spacing cursor position, scroll position, position in world space
+    - gets lexed from tree-sitter
+        - if the text changes re lex and set color 
+    - lines<Line>
+        - tokens<Token>
+    - render options, underline lines, border text-window , font-size,..
+    - can handle token rotation (so all tokens follow player in a specific rotation (makes it easy to read the text -- is actually implemented))
+    - can render the tokens
+
+Text::Line
+    - position, linenumber, shallRenderNumbers, line height, word spacing
+    - tokens<Token>
+    - can belong to Text
+
+Text::Token
+    - color from Text tokenizer or
+      color from single token linting if not belonging to a text (save that value) or
+      color because the color shall be a static color (can get set)
+    - gets parsed from tree-sitter if not already known
+        - list of tookens for fast lookup if creating new ones
+    - has a position in world space
+    - can get picked up and shot with projectile
+    - can belong to Line
+    - rotate to players eye horizontally
+    - rotate to players eye vertically
+    - rotate to players eye horizontally and vertically
+    - (old Text3d)
+
+Token::Ai
+    - jiggle
+    - hover
+    - attack player (steal token/drop token)
+    - flee from player
+
+A Token can  shoot another Token out of a Text with a Projectile. 
+
+I can pick up tokens out of a Text (as we currently can from the EditorWall)
+
+Linting is an asyncroun job. before Token get a default color. We take the Linting when it is ready , we dont wait for it. We just need to recreate the text mesh if the token key has changed.
+
+Tokenizer(TreeSitter)
+    - lexes in background and applies color and key when found to token.
+    - Lexes when Text changed
+
+Wall::Editor
+    - can own Text
+    - vim like editing option
+    - fixes Text position on the wall
+    - i can shoot words inbetween other words or lines (as we can)
+    
+Text
+    - big namespace with classes and structs
+    - Text in plain
+        - we might want to store that tokenized or in lines or in lines and tokenized depending on whats enabled
+    - can get rendered
+        - has a default color
+        - can jiggle
+        - can get editted with vim like style at the editor wall par example
+    - can be a label/get gicked/ etc
+    - can get tokenized for tokenized-coloring (tree-sitter)
+    - can get scrolled to a specific position
+
+
+struct Text 
+{
+    
+}
+----------------------------
+
 - Dont pick up words through floor
 
 
diff --git a/src/core/game.cpp b/src/core/game.cpp
index 871e286..dcea0f0 100644
--- a/src/core/game.cpp
+++ b/src/core/game.cpp
@@ -1,37 +1,32 @@
 #include <filesystem>
 #include <system_error>
 
-#include "game.hpp"
-#include "utils/config.hpp"
 #include "entities/world.hpp"
-#include "states/istate.hpp"
+#include "game.hpp"
 #include "states/game_state.hpp"
+#include "states/istate.hpp"
 #include "states/menu_state.hpp"
 #include "systems/input_system.hpp"
 #include "systems/physics_system.hpp"
+#include "utils/config.hpp"
 
 
 Game::~Game() = default;
 
+
 bool
 Game::init()
 {
     std::error_code ec;
     std::filesystem::create_directories("/tmp/game_mod_include", ec);
-    std::filesystem::copy_file("src/systems/game_mod_abi.h",
-                               "/tmp/game_mod_include/game_mod_abi.h",
-                               std::filesystem::copy_options::overwrite_existing, ec);
+    std::filesystem::copy_file         ("src/systems/game_mod_abi.h",
+                                        "/tmp/game_mod_include/game_mod_abi.h",
+                                        std::filesystem::copy_options::overwrite_existing, ec);
     if (ec) SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
                         "Could not stage game_mod_abi.h: %s", ec.message().c_str());
 
-
-    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
-    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
-    SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
-    SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
-    SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
-    SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, 1);
-    SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, 4);
+    _gl_renderer = cpputils::make_unique<GlRenderer>();
+    _gl_renderer->pre_Config();
 
     _window = SDL_CreateWindow("game", 640, 480, SDL_WINDOW_RESIZABLE | SDL_WINDOW_OPENGL);
     if (!_window) {
@@ -39,12 +34,11 @@ Game::init()
         return false;
     }
 
-    SDL_SetWindowRelativeMouseMode(_window, true);
-
-    _gl_renderer = cpputils::make_unique<GlRenderer>();
-    if (!_gl_renderer->init(_window)) return false;
+    if (!_gl_renderer->init(_window))
+        return false;
 
-    SDL_StartTextInput(_window);
+    SDL_SetWindowRelativeMouseMode(_window, true);
+    SDL_StartTextInput            (_window);
 
     change_State(cpputils::make_unique<MenuState>());
     return true;
@@ -54,32 +48,32 @@ Game::init()
 void
 Game::change_State(cpputils::unique_ptr<IState> next)
 {
-    if (m_state) m_state->on_Exit(*this);
+    if (_state) _state->on_Exit(*this);
 
-    m_state = std::move(next);
+    _state = std::move(next);
 
-    if (m_state) m_state->on_Enter(*this);
+    if (_state) _state->on_Enter(*this);
 }
 
 
 void
 Game::handle_Event(const SDL_Event& event)
 {
-    if (m_state) m_state->handle_Event(*this, event);
+    if (_state) _state->handle_Event(*this, event);
 }
 
 
 void
 Game::update(double dt_ns)
 {
-    if (m_state) m_state->update(dt_ns * 1e-9, _gl_renderer.get());
+    if (_state) _state->update(dt_ns * 1e-9, _gl_renderer.get());
 }
 
 
 void
 Game::render()
 {
-    if (m_state) m_state->render(*_window, *_gl_renderer);
+    if (_state) _state->render(*_window, *_gl_renderer);
 }
 
 
diff --git a/src/core/game.hpp b/src/core/game.hpp
index 7aa3f78..949de2b 100644
--- a/src/core/game.hpp
+++ b/src/core/game.hpp
@@ -13,8 +13,8 @@ class Game
 {
     SDL_Window*   _window     {};
     GlRendererPtr _gl_renderer{};
-
-    cpputils::unique_ptr<IState> m_state;
+    cpputils::unique_ptr<IState>
+                  _state;
 public:
     ~Game();
 
diff --git a/src/core/main.cpp b/src/core/main.cpp
index 172d3db..a2f545e 100644
--- a/src/core/main.cpp
+++ b/src/core/main.cpp
@@ -23,10 +23,7 @@ int main()
         return 1;
     }
 
-    // IDK About that...
-    // Cap any single dt to ~100ms. Beyond that we treat it as a hitch and
-    // skip the lost time rather than feeding a huge dt into physics, AI,
-    // animations, etc. Without this clamp a single OS pause ruins the next
+    // Without this clamp a single OS pause ruins the next
     // physics substep AND poisons profiler readings.
     constexpr Uint64 MAX_DT_NS = 100'000'000ull;   // 100 ms
 
diff --git a/src/entities/player.cpp b/src/entities/player.cpp
index 6d4f7c6..e8f70a0 100644
--- a/src/entities/player.cpp
+++ b/src/entities/player.cpp
@@ -8,7 +8,8 @@ Player::Player()
          radius(0.5f),
          height(1.5f),
          pos   {8.0, 0.0, 8.0},
-         vel   {0.0, 0.0, 0.0} {}
+         vel   {0.0, 0.0, 0.0}
+{}
 
 void
 Player::apply_Collision(const PlayerCollision& col)
diff --git a/src/entities/pult.cpp b/src/entities/pult.cpp
index e0521b5..acd19bb 100644
--- a/src/entities/pult.cpp
+++ b/src/entities/pult.cpp
@@ -30,25 +30,23 @@ PultButton::tick(double dt)
 void
 PultButton::draw(GlRenderer& gl, const Camera& cam, const Light& light) const
 {
-    Mat4 view  = buildViewMat4(cam);
+    Mat4 view  = buildViewMat4    (cam);
+    Mat4 model = buildModelMat4   (pos, yaw);
     Mat4 proj  = gl.get_Projection();
-    Mat4 model = buildModelMat4(pos, yaw);
-    Mat4 mvp   = Mat4::mul(proj, Mat4::mul(view, model));
+    Mat4 mvp   = Mat4::mul        (proj, Mat4::mul(view, model));
 
     const float hx = static_cast<float>(shape.half_extents.x);
     const float hy = static_cast<float>(shape.half_extents.y);
     const float hz = static_cast<float>(shape.half_extents.z);
 
     gl.set_Wireframe(false);
-    gl.set_MVP(model, mvp);
+    gl.set_MVP      (model, mvp);
     gl.set_NormalMat(model);
-    gl.set_Light(light);
-
-    gl.set_Color(pressed ? 1.0f : 0.35f,
-                 pressed ? 1.0f : 0.75f,
-                 pressed ? 0.2f : 0.35f);
-
-    gl.draw_Box(hx, hy, hz);
+    gl.set_Light    (light);
+    gl.set_Color    (pressed ? 1.0f : 0.35f,
+                     pressed ? 1.0f : 0.75f,
+                     pressed ? 0.2f : 0.35f);
+    gl.draw_Box     (hx, hy, hz);
 }
 
 
@@ -66,11 +64,11 @@ Pult::Pult(const std::string& n, Vec3 world_pos,
            false)
      , name(n), half_w(half_width), half_d(half_depth)
 {
-    label.set_Billboard(true);
-    label.set_Style(ConfigColor::teal, Text3d::depth_wall, 0.0025f);
+    label.set_Billboard (true);
+    label.set_Style     (ConfigColor::teal, Text3d::depth_wall, 0.0025f);
     label.set_Collidable(true);
-    label.attach_To(&pos, Vec3{ 0.0, ConfigPult::label_height, 0.0 });
-    label.set_Text(n);
+    label.attach_To     (&pos, Vec3{ 0.0, ConfigPult::label_height, 0.0 });
+    label.set_Text      (n);
 }
 
 
@@ -82,17 +80,17 @@ Pult::tick(double dt)
 void
 Pult::draw(GlRenderer& gl, const Camera& cam, const Light& light) const
 {
-    Mat4 view  = buildViewMat4(cam);
+    Mat4 view  = buildViewMat4    (cam);
+    Mat4 model = buildModelMat4   (pos, yaw);
     Mat4 proj  = gl.get_Projection();
-    Mat4 model = buildModelMat4(pos, yaw);
-    Mat4 mvp   = Mat4::mul(proj, Mat4::mul(view, model));
+    Mat4 mvp   = Mat4::mul        (proj, Mat4::mul(view, model));
 
     gl.set_Wireframe(false);
-    gl.set_MVP(model, mvp);
+    gl.set_MVP      (model, mvp);
     gl.set_NormalMat(model);
-    gl.set_Light(light);
-    gl.set_Color(0.45f, 0.45f, 0.65f);
-    gl.draw_Box(half_w, H, half_d);
+    gl.set_Light    (light);
+    gl.set_Color    (0.45f, 0.45f, 0.65f);
+    gl.draw_Box     (half_w, H, half_d);
 
     for (const PultButton* b : buttons)
         b->draw(gl, cam, light);
diff --git a/src/entities/world.cpp b/src/entities/world.cpp
index 63818a5..16e6096 100644
--- a/src/entities/world.cpp
+++ b/src/entities/world.cpp
@@ -1,10 +1,13 @@
 #include "entities/world.hpp"
+#include "utils/frame_profiler.hpp"
 
 
 World::World()
 {
-    // initEdges();
     _wall_surfaces.generate(get_BoxScale());
+
+    _floor_surface = cpputils::make_unique<Floor::Mandelbrot>();
+    _floor_surface->generate(World::get_BoxScale(), World::get_FloorY());
 }
 
 
@@ -53,33 +56,12 @@ World::resolve_Player(const Player& player) const
 void
 World::draw(GlRenderer& gl, const Camera& camera, const Light& light) const
 {
-    Mat4 view  = buildViewMat4(camera);
-    Mat4 proj  = gl.get_Projection();
-    Mat4 model = Mat4::identity();
-    Mat4 mvp   = Mat4::mul(proj, Mat4::mul(view, model));
-
-    // Crosshair: 2D overlay in NDC. Uses identity MVP, then restores the
-    // world MVP so subsequent draws (_wall_surfaces) aren't affected.
-    {
-        Mat4 ident = Mat4::identity();
-        gl.set_MVP(model, ident);
-        gl.set_Color(1.0f, 1.0f, 1.0f);
-
-        float cx_size = 0.02f;
-        float pts[] = {
-             0.0f, -cx_size, 0.0f,
-             0.0f,  cx_size, 0.0f,
-            -cx_size,  0.0f, 0.0f,
-             cx_size,  0.0f, 0.0f,
-        };
-        gl.draw_Lines(pts, 4);
-
-        // Restore world MVP for everything drawn after the crosshair.
-        gl.set_MVP(model, mvp);
+    { FP_ZONE("world.draw.wall");
+        _wall_surfaces.draw(gl, camera, light); }
+    { FP_ZONE("world.draw.floor");
+        _floor_surface->draw(gl, camera, light);
     }
 
-    // ── Solid wall surfaces ─────────────────────────────────────────────
-    _wall_surfaces.draw(gl, camera, light);
 }
 
 
diff --git a/src/entities/world.hpp b/src/entities/world.hpp
index 583a205..40b2181 100644
--- a/src/entities/world.hpp
+++ b/src/entities/world.hpp
@@ -2,7 +2,9 @@
 
 #include <array>
 
+#include "utils/globals.hpp"
 #include "entities/player.hpp"
+#include "rendering/floor_surface.hpp"
 #include "rendering/gl_renderer.hpp"
 #include "wall/wall.hpp"
 
@@ -17,9 +19,12 @@ struct SweepResult
 };
 
 
+namespace Floor { struct Surface; }
+
 class World
 {
     Wall::Surfaces _wall_surfaces;
+    cpputils::unique_ptr<Floor::Surface> _floor_surface;
 public:
     World();
 
diff --git a/src/systems/level_director.cpp b/src/levels/director.cpp
similarity index 91%
rename from src/systems/level_director.cpp
rename to src/levels/director.cpp
index ef75de5..99cbf22 100644
--- a/src/systems/level_director.cpp
+++ b/src/levels/director.cpp
@@ -6,23 +6,27 @@
 #include "entities/plate.hpp"
 #include "entities/solid_box.hpp"
 #include "entities/world.hpp"
+#include "levels/director.hpp"
 #include "levels/level.hpp"
 #include "states/game_context.hpp"
 #include "systems/celebration_system.hpp"
-#include "systems/level_director.hpp"
 #include "systems/particle_system.hpp"
 #include "utils/config.hpp"
 #include "utils/text.hpp"
 #include "wall/wall.hpp"
 
 
-LevelDirector::LevelDirector(const Level::Data& level)
-    : _runner(cpputils::make_unique<LevelRunner>(level))
+namespace
+Level
+{
+
+Director::Director(const Level::Data& level)
+    : _runner(cpputils::make_unique<Level::Progression>(level))
 {}
 
 
 int
-LevelDirector::get_MaxAiForStep() const
+Director::get_MaxAiForStep() const
 {
     if (_runner->is_Complete()) return 0;
     return _runner->get_Current().max_ai_words;
@@ -30,7 +34,7 @@ LevelDirector::get_MaxAiForStep() const
 
 
 void
-LevelDirector::enter_Step(GameContext& ctx)
+Director::enter_Step(GameContext& ctx)
 {
     spawn_Grants(ctx);
     setup_Scene(ctx, _runner->get_Current().scene);
@@ -40,7 +44,7 @@ LevelDirector::enter_Step(GameContext& ctx)
 
 
 void
-LevelDirector::spawn_Grants(GameContext& ctx)
+Director::spawn_Grants(GameContext& ctx)
 {
     DynArray<std::string> tokens = _runner->get_GarantsForStep();
 
@@ -69,7 +73,7 @@ LevelDirector::spawn_Grants(GameContext& ctx)
 
 
 void
-LevelDirector::setup_Scene(GameContext& ctx, const Scene::Data& scene)
+Director::setup_Scene(GameContext& ctx, const Scene::Data& scene)
 {
     for (const Scene::Placed::Word& pw : scene.words) {
         Text3dPtr t = make_Text3d(pw.text, Text3d::depth_floor, 0.003f, pw.pos, pw.color);
@@ -122,7 +126,7 @@ LevelDirector::setup_Scene(GameContext& ctx, const Scene::Data& scene)
 
 
 void
-LevelDirector::teardown_Scene(GameContext& ctx)
+Director::teardown_Scene(GameContext& ctx)
 {
     if (!_step_scenery.empty()) {
         std::unordered_set<GameObject*> scenery_set(_step_scenery.begin(), _step_scenery.end());
@@ -139,7 +143,7 @@ LevelDirector::teardown_Scene(GameContext& ctx)
 
 
 void
-LevelDirector::show_Details(GameContext& ctx) const
+Director::show_Details(GameContext& ctx) const
 {
     ctx.clear_WallRole(Wall::Role::Info);
 
@@ -170,7 +174,7 @@ LevelDirector::show_Details(GameContext& ctx) const
 
 
 Goal::Context
-LevelDirector::build_GoalCtx(const GameContext& ctx) const
+Director::build_GoalCtx(const GameContext& ctx) const
 {
     Goal::Context gctx;
 
@@ -193,8 +197,8 @@ LevelDirector::build_GoalCtx(const GameContext& ctx) const
 }
 
 
-LevelDirector::AdvanceResult
-LevelDirector::try_Advance(GameContext& ctx, double dt,
+Director::AdvanceResult
+Director::try_Advance(GameContext& ctx, double dt,
                            CelebrationSystem& celebration, ParticleSystem& particles)
 {
     _step_age += dt;
@@ -225,3 +229,6 @@ LevelDirector::try_Advance(GameContext& ctx, double dt,
     celebration.start(*ctx.camera, glyph_yaw);
     return res;
 }
+
+
+} // namespace Level
diff --git a/src/systems/level_director.hpp b/src/levels/director.hpp
similarity index 80%
rename from src/systems/level_director.hpp
rename to src/levels/director.hpp
index 543ce34..f98a5a6 100644
--- a/src/systems/level_director.hpp
+++ b/src/levels/director.hpp
@@ -7,7 +7,7 @@
 #include "levels/goal.hpp"
 #include "levels/level.hpp"
 #include "levels/scene.hpp"
-#include "systems/level_runner_system.hpp"
+#include "levels/progression.hpp"
 #include "utils/globals.hpp"
 
 
@@ -19,6 +19,9 @@ class  WordAiSystem;
 struct GameContext;
 
 
+namespace Level
+{
+
 struct StepLedger
 {
     DynArray<PultAction>  buttons_pressed;
@@ -46,9 +49,9 @@ struct StepPlate
 };
 
 
-class LevelDirector
+class Director
 {
-    cpputils::unique_ptr<LevelRunner>
+    cpputils::unique_ptr<Level::Progression>
            _runner;
     double _step_age { 0.0 };
 
@@ -69,7 +72,7 @@ public:
         bool reset_carry {false};   // next step requests carry/projectile wipe
     };
 
-    explicit LevelDirector(const Level::Data& level);
+    explicit Director(const Level::Data& level);
 
     void enter_Step(GameContext& ctx);
 
@@ -88,9 +91,12 @@ public:
 
     bool                 is_LevelComplete() const { return _runner->is_Complete(); }
 
-    LevelRunner&         get_Runner()             { return *_runner; }
-    const LevelRunner&   get_Runner()       const { return *_runner; }
-    const std::string&   get_CurrentHint()  const { return _runner->get_Current().hint; }
-    int                  get_MaxAiForStep() const;
-    DynArray<StepPlate>& get_StepPlates()         { return _step_plates; }
+    Level::Progression&       get_Runner()             { return *_runner; }
+    const Level::Progression& get_Runner()       const { return *_runner; }
+    const std::string&        get_CurrentHint()  const { return _runner->get_Current().hint; }
+    int                       get_MaxAiForStep() const;
+    DynArray<StepPlate>&      get_StepPlates()         { return _step_plates; }
 };
+
+
+} // namespace Level
diff --git a/src/systems/level_runner_system.cpp b/src/levels/progression.cpp
similarity index 58%
rename from src/systems/level_runner_system.cpp
rename to src/levels/progression.cpp
index 388d186..ecba100 100644
--- a/src/systems/level_runner_system.cpp
+++ b/src/levels/progression.cpp
@@ -1,9 +1,13 @@
-#include "level_runner_system.hpp"
+#include "progression.hpp"
 
-LevelRunner::LevelRunner(const Level::Data& l) : _level(&l) {}
+
+namespace Level
+{
+
+Progression::Progression(const Level::Data& l) : _level(&l) {}
 
 
-bool LevelRunner::advance()
+bool Level::Progression::advance()
 {
     _current_step++;
     return !is_Complete();
@@ -11,7 +15,7 @@ bool LevelRunner::advance()
 
 
 DynArray<std::string>
-LevelRunner::get_GarantsForStep() const
+Progression::get_GarantsForStep() const
 {
     DynArray<std::string> result;
     for (const Level::TokenGrant& grant : get_Current().grants)
@@ -19,3 +23,6 @@ LevelRunner::get_GarantsForStep() const
             result.push_back(c);
     return result;
 }
+
+
+} // namespace
diff --git a/src/levels/progression.hpp b/src/levels/progression.hpp
new file mode 100644
index 0000000..8bcd1df
--- /dev/null
+++ b/src/levels/progression.hpp
@@ -0,0 +1,33 @@
+#pragma once
+
+#include "levels/level.hpp"
+
+
+namespace Level
+{
+
+class Progression
+{
+    const Data*
+           _level;
+
+    size_t _current_step{0};
+public:
+    explicit Progression(const Data& _level);
+
+    bool   advance ();
+
+    bool   is_Complete   () const { return _current_step >= _level->steps.size(); }
+    bool   is_DynamicLib () const { return _level->dynamic_lib; }
+
+    size_t get_Step      () const { return _current_step; }
+    const Level::Step&   
+           get_Current   () const { return _level->steps[_current_step]; }
+    const std::string&
+           get_SourceFile() const { return _level->source_file; }
+    DynArray<std::string>
+           get_GarantsForStep() const;
+};
+
+
+} // namespace
diff --git a/src/rendering/floor_mandelbrot.hpp b/src/rendering/floor_mandelbrot.hpp
deleted file mode 100644
index 6f5a269..0000000
--- a/src/rendering/floor_mandelbrot.hpp
+++ /dev/null
@@ -1,25 +0,0 @@
-#pragma once
-
-#include <SDL3/SDL.h>
-
-#include "rendering/gl_renderer.hpp"
-#include "utils/globals.hpp"
-#include "utils/light.hpp"
-
-
-class Camera;
-
-
-class FloorMandelbrot
-{
-    DynArray<int>   _indices;
-    DynArray<float> _verts;
-public:
-    void generate(float  world_scale,
-                  float  floor_y,
-                  double center_x = -0.5,
-                  double center_y =  0.0,
-                  double width    =  3.0);
-
-    void draw(GlRenderer& gl, const Camera& cam, const Light& light) const;
-};
diff --git a/src/rendering/floor_mandelbrot.cpp b/src/rendering/floor_surface.cpp
similarity index 85%
rename from src/rendering/floor_mandelbrot.cpp
rename to src/rendering/floor_surface.cpp
index 2001635..9ce4685 100644
--- a/src/rendering/floor_mandelbrot.cpp
+++ b/src/rendering/floor_surface.cpp
@@ -1,15 +1,18 @@
 #include <SDL3/SDL.h>
 
 #include "entities/camera.hpp"
-#include "floor_mandelbrot.hpp"
+#include "floor_surface.hpp"
 
 
+namespace Floor
+{
+
 void
-FloorMandelbrot::generate(float  world_scale,
-                          float  floor_y,
-                          double center_x,
-                          double center_y,
-                          double width)
+Mandelbrot::generate(float  world_scale,
+                     float  floor_y,
+                     double center_x,
+                     double center_y,
+                     double width)
 {
     _verts.clear();
     _indices.clear();
@@ -18,7 +21,7 @@ FloorMandelbrot::generate(float  world_scale,
     // layout: [pos(3) | normal(3) | color(3)], but here we *abuse* the color
     // slot to carry the complex-plane coordinate (cx, cy, 0). The shader
     // reads it from vColor when in Mandelbrot mode.
-    const float ws = world_scale;
+    const float  ws = world_scale;
     const double half_w = width * 0.5;
 
     // Four corners of the floor in world space.
@@ -56,7 +59,7 @@ FloorMandelbrot::generate(float  world_scale,
 
 
 void
-FloorMandelbrot::draw(GlRenderer& gl, const Camera& cam, const Light& light) const
+Mandelbrot::draw(GlRenderer& gl, const Camera& cam, const Light& light) const
 {
     if (_verts.empty()) return;
 
@@ -78,3 +81,5 @@ FloorMandelbrot::draw(GlRenderer& gl, const Camera& cam, const Light& light) con
                              static_cast<int>(_indices.size()));
     gl.enable_MandelbrotMode(false);
 }
+
+} // namespace Floor
diff --git a/src/rendering/floor_surface.hpp b/src/rendering/floor_surface.hpp
new file mode 100644
index 0000000..1176fbc
--- /dev/null
+++ b/src/rendering/floor_surface.hpp
@@ -0,0 +1,44 @@
+#pragma once
+
+#include <SDL3/SDL.h>
+
+#include "rendering/gl_renderer.hpp"
+#include "utils/globals.hpp"
+#include "utils/light.hpp"
+
+
+class Camera;
+
+
+namespace Floor
+{
+
+class Surface
+{
+protected:
+    DynArray<int>   _indices;
+    DynArray<float> _verts;
+public:
+    virtual void generate(float  world_scale,
+                  float  floor_y,
+                  double center_x = -0.5,
+                  double center_y =  0.0,
+                  double width    =  3.0) = 0;
+
+    virtual void draw(GlRenderer& gl, const Camera& cam, const Light& light) const = 0;
+    virtual ~Surface() = default;
+};
+
+class Mandelbrot final : public Surface
+{
+public:
+    void generate(float  world_scale,
+                  float  floor_y,
+                  double center_x = -0.5,
+                  double center_y =  0.0,
+                  double width    =  3.0) override;
+
+    void draw(GlRenderer& gl, const Camera& cam, const Light& light) const override;
+};
+
+} // namespace Floor
diff --git a/src/rendering/gl_renderer.cpp b/src/rendering/gl_renderer.cpp
index 88ce1be..8743c81 100644
--- a/src/rendering/gl_renderer.cpp
+++ b/src/rendering/gl_renderer.cpp
@@ -409,9 +409,20 @@ Mat4 buildModelMat4(const Vec3& pos, float yaw, float pitch)
     return m;
 }
 
+
 // ─────────────────────────────────────────────────────────────────────────────
 // GlRenderer::init
 // ─────────────────────────────────────────────────────────────────────────────
+void GlRenderer::pre_Config() {
+    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
+    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
+    SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
+    SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
+    SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
+    SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, 1);
+    SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, 4);
+}
+
 bool GlRenderer::init(SDL_Window* window)
 {
     _ctx = SDL_GL_CreateContext(window);
@@ -792,11 +803,13 @@ void GlRenderer::draw_Mesh(const float* verts,   int vert_count,
                           const int*   indices, int index_count)
 {
     glBindVertexArray(_vao);
-    glBindBuffer(GL_ARRAY_BUFFER, _vbo);
-    glBufferData(GL_ARRAY_BUFFER, static_cast<size_t>(vert_count)*6*sizeof(float), verts, GL_STREAM_DRAW);
-    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, _ebo);
-    glBufferData(GL_ELEMENT_ARRAY_BUFFER, static_cast<size_t>(index_count)*sizeof(int), indices, GL_STREAM_DRAW);
-    glDrawElements(GL_TRIANGLES, index_count, GL_UNSIGNED_INT, nullptr);
+
+    glBindBuffer     (GL_ARRAY_BUFFER, _vbo);
+    glBufferData     (GL_ARRAY_BUFFER, static_cast<size_t>(vert_count)*6*sizeof(float), verts, GL_STREAM_DRAW);
+    glBindBuffer     (GL_ELEMENT_ARRAY_BUFFER, _ebo);
+    glBufferData     (GL_ELEMENT_ARRAY_BUFFER, static_cast<size_t>(index_count)*sizeof(int), indices, GL_STREAM_DRAW);
+    glDrawElements   (GL_TRIANGLES, index_count, GL_UNSIGNED_INT, nullptr);
+
     glBindVertexArray(0);
 }
 
@@ -806,10 +819,11 @@ GlRenderer::draw_MeshColored(const float* verts,   int vert_count,
                             const int*   indices, int index_count)
 {
     glBindVertexArray(_vao_c);
-    glBindBuffer(GL_ARRAY_BUFFER, _vbo_c);
-    glBufferData(GL_ARRAY_BUFFER,
-                 static_cast<size_t>(vert_count) * 9 * sizeof(float),
-                 verts, GL_STATIC_DRAW);
+
+    glBindBuffer     (GL_ARRAY_BUFFER, _vbo_c);
+    glBufferData     (GL_ARRAY_BUFFER,
+                      static_cast<size_t> (vert_count) * 9 * sizeof(float),
+                      verts, GL_STATIC_DRAW);
 
     // Re-point attributes at _vbo_c — draw_MeshCached may have repointed
     // this shared VAO at a per-Text3d VBO.
@@ -821,7 +835,8 @@ GlRenderer::draw_MeshColored(const float* verts,   int vert_count,
     glBufferData(GL_ELEMENT_ARRAY_BUFFER,
                  static_cast<size_t>(index_count) * sizeof(int),
                  indices, GL_STATIC_DRAW);
-    glDrawElements(GL_TRIANGLES, index_count, GL_UNSIGNED_INT, nullptr);
+    glDrawElements   (GL_TRIANGLES, index_count, GL_UNSIGNED_INT, nullptr);
+
     glBindVertexArray(0);
 }
 
@@ -832,7 +847,7 @@ GlRenderer::draw_MeshCached(unsigned int& vbo, unsigned int& ebo, int& index_cou
                             const float* verts, int vert_count,
                             const int* indices, int idx_count)
 {
-    glBindVertexArray(_vao_c);   // reuse the 9-float pos|normal|color layout
+    glBindVertexArray(_vao_c);
 
     if (vbo == 0) glGenBuffers(1, &vbo);
     if (ebo == 0) glGenBuffers(1, &ebo);
@@ -856,6 +871,7 @@ GlRenderer::draw_MeshCached(unsigned int& vbo, unsigned int& ebo, int& index_cou
     glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, 9*sizeof(float), (void*)(6*sizeof(float)));
 
     glDrawElements(GL_TRIANGLES, index_count, GL_UNSIGNED_INT, nullptr);
+
     glBindVertexArray(0);
 }
 
@@ -872,9 +888,11 @@ GlRenderer::free_MeshBuffers(unsigned int vbo, unsigned int ebo)
 void GlRenderer::draw_Lines(const float* points, int point_count)
 {
     glBindVertexArray(_line_vao);
+
     glBindBuffer(GL_ARRAY_BUFFER, _line_vbo);
     glBufferData(GL_ARRAY_BUFFER, static_cast<size_t>(point_count)*3*sizeof(float), points, GL_STREAM_DRAW);
     glDrawArrays(GL_LINES, 0, point_count);
+
     glBindVertexArray(0);
 }
 
@@ -883,14 +901,16 @@ void GlRenderer::draw_Menu()
     glUseProgram(_prog);
     Mat4 identity = Mat4::identity();
     glUniformMatrix4fv(_uMVP, 1, GL_FALSE, identity.m);
+
     float n[9] = {1,0,0, 0,1,0, 0,0,1};
     glUniformMatrix3fv(_uNormalMat, 1, GL_FALSE, n);
-    glUniform3f(_uColor,    0.2f, 0.4f, 0.8f);
-    glUniform3f(_uLightDir, 0.0f, 0.0f, 1.0f);
-    glUniform3f(_uAmbient,  1.0f, 1.0f, 1.0f);  // full ambient = flat colour
-    glUniform1f(_uIntensity, 1.0f);
-    glUniform1i(_uWireframe, 1);
-    glUniform3f(_uSunColor, 1.0f, 1.0f, 1.0f);
+
+    glUniform3f(_uColor,      0.2f, 0.4f, 0.8f);
+    glUniform3f(_uLightDir,   0.0f, 0.0f, 1.0f);
+    glUniform3f(_uAmbient,    1.0f, 1.0f, 1.0f);
+    glUniform1f(_uIntensity,  1.0f);
+    glUniform1i(_uWireframe,  1);
+    glUniform3f(_uSunColor,   1.0f, 1.0f, 1.0f);
     glUniform1i(_uMandelbrot, 0);
     glUniform1i(_uWallPaper,  0);
     glUniform3f(_uFlashPos,   0.0f, 0.0f, 0.0f);
@@ -900,11 +920,15 @@ void GlRenderer::draw_Menu()
     glUniform1f(_uFlashInnerCos,  0.97f);
     glUniform1f(_uFlashOuterCos,  0.90f);
     glUniform1f(_uFlashIntensity, 0.0f);
+
     Mat4 ident_m = Mat4::identity();
     glUniformMatrix4fv(_uModel, 1, GL_FALSE, ident_m.m);
 
+
     glBindVertexArray(_menu_vao);
+
     glDrawArrays(GL_TRIANGLES, 0, 3);
+
     glBindVertexArray(0);
 }
 
@@ -958,13 +982,14 @@ GlRenderer::draw_Emissive(const Camera& cam,
 GLuint GlRenderer::compile_Shader(GLenum type, const char* src)
 {
     GLuint s = glCreateShader(type);
-    glShaderSource(s, 1, &src, nullptr);
+    glShaderSource (s, 1, &src, nullptr);
     glCompileShader(s);
     GLint ok; glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
     if (!ok) {
         char log[512]; glGetShaderInfoLog(s,512,nullptr,log);
         SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Shader compile error: %s", log);
-        glDeleteShader(s); return 0;
+        glDeleteShader(s);
+        return 0;
     }
     return s;
 }
@@ -973,21 +998,24 @@ bool GlRenderer::link_Program(GLuint vert, GLuint frag)
 {
     _prog = glCreateProgram();
     glAttachShader(_prog, vert); glAttachShader(_prog, frag);
-    glLinkProgram(_prog);
+    glLinkProgram (_prog);
     GLint ok; glGetProgramiv(_prog, GL_LINK_STATUS, &ok);
     if (!ok) {
         char log[512]; glGetProgramInfoLog(_prog,512,nullptr,log);
         SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Program link error: %s", log);
-        glDeleteProgram(_prog); _prog=0; return false;
+        glDeleteProgram(_prog); _prog=0;
+        return false;
     }
     return true;
 }
 
 float
-GlRenderer::get_Aspect() const { return _aspect; }
+GlRenderer::get_Aspect() const
+{ return _aspect; }
 
 const Mat4&
-GlRenderer::get_Projection() const { return _proj; }
+GlRenderer::get_Projection() const
+{ return _proj; }
 
 void
 GlRenderer::enable_MandelbrotMode(bool on)
@@ -998,7 +1026,6 @@ GlRenderer::enable_WallPaperMode(bool on)
 { glUniform1i(_uWallPaper, on ? 1 : 0); }
 
 
-
 void
 GlRenderer::create_FullscreenQuad_()
 {
diff --git a/src/rendering/gl_renderer.hpp b/src/rendering/gl_renderer.hpp
index b8e3e59..4e57bee 100644
--- a/src/rendering/gl_renderer.hpp
+++ b/src/rendering/gl_renderer.hpp
@@ -138,6 +138,8 @@ class GlRenderer
 public:
     static void free_MeshBuffers(unsigned int vbo, unsigned int ebo);
 
+
+    void pre_Config     ();
     bool init           (SDL_Window* window);
     void begin_Frame    (int viewport_w, int viewport_h);
     void end_Frame      (SDL_Window* window);
diff --git a/src/wall/surfaces.cpp b/src/rendering/wall_surfaces.cpp
similarity index 99%
rename from src/wall/surfaces.cpp
rename to src/rendering/wall_surfaces.cpp
index 6d2fa57..5f23d18 100644
--- a/src/wall/surfaces.cpp
+++ b/src/rendering/wall_surfaces.cpp
@@ -1,12 +1,11 @@
 #include <SDL3/SDL.h>
 
 #include "entities/camera.hpp"
-#include "wall.hpp"
-
 #include "rendering/gl_renderer.hpp"
 #include "utils/globals.hpp"
 #include "utils/light.hpp"
 #include "utils/math.hpp"
+#include "wall/wall.hpp"
 
 
 
diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index b8b00a4..b3002da 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -48,29 +48,27 @@ GameState::GameState(size_t starting_level)
     _ctx.light  = cpputils::make_unique<Light> ();
 
     _input_sys       = cpputils::make_unique<InputSystem>       ();
-    _physics_sys     = cpputils::make_unique<PhysicsSystem>     ();
-    _projectiles_sys = cpputils::make_unique<ProjectileSystem>  ();
+    _freelook_sys    = cpputils::make_unique<FreelookSystem>    ();
+
     _compiler_sys    = cpputils::make_unique<CompilerSystem>    ();
     _program_sys     = cpputils::make_unique<ProgramSystem>     ();
-    _word_anim_sys   = cpputils::make_unique<WordAnimSystem>    ();
-    _freelook_sys    = cpputils::make_unique<FreelookSystem>    ();
-    _particle_sys    = cpputils::make_unique<ParticleSystem>    ();
     _dyn_loader_sys  = cpputils::make_unique<DynLoaderSystem>   ();
-    _celebration_sys = cpputils::make_unique<CelebrationSystem> ();
-    _highlight_sys   = cpputils::make_unique<CppHighlightSystem>();
 
-    _floor_sys       = cpputils::make_unique<FloorMandelbrot>  ();
-    _floor_sys->generate(World::get_BoxScale(), World::get_FloorY());
-
-    _wall_labels     = Wall::Label::generate_All(World::get_BoxScale());
+    _physics_sys     = cpputils::make_unique<PhysicsSystem>     ();
+    _celebration_sys = cpputils::make_unique<CelebrationSystem> ();
 
+    _projectiles_sys = cpputils::make_unique<ProjectileSystem>  ();
+    _particle_sys    = cpputils::make_unique<ParticleSystem>    ();
+    _word_anim_sys   = cpputils::make_unique<WordAnimSystem>    ();
     _word_ai_sys     = cpputils::make_unique<WordAiSystem>();
+    _highlight_sys   = cpputils::make_unique<CppHighlightSystem>();
 
+    _wall_labels     = Wall::Label::generate_All(World::get_BoxScale());
     _pult_sys        = cpputils::make_unique<PultSystem>  ();
     _pult_sys->init(_ctx.scenery, _ctx.buttons, World::get_BoxScale());
 
     if (starting_level >= Level::get_Count()) starting_level = 0;
-    _level_director  = cpputils::make_unique<LevelDirector>(Level::get_Data(starting_level));
+    _level_director  = cpputils::make_unique<Level::Director>(Level::get_Data(starting_level));
 
     _level_director->enter_Step(_ctx);
     _word_ai_sys->set_MaxActiveAi(_level_director->get_MaxAiForStep());
@@ -132,11 +130,11 @@ GameState::carriedline_Update()
 
     if (fs.active) return;
 
-    const Vec3   anchor    = CarryMath::get_LineAnchor(*_ctx.camera, CarryMath::CARRY_DIST);
+    const Vec3   anchor    = CarriedLine::get_LineAnchor(*_ctx.camera, ConfigCarry::dist);
     const Vec3   right     = _ctx.camera->right;
     const double glyph_yaw = SDL_atan2(-_ctx.camera->fwd.x, -_ctx.camera->fwd.z);
 
-    DynArray<Vec3> slots = CarryMath::get_SlotPosition(_carried, anchor, right, CarryMath::CARRY_GAP);
+    DynArray<Vec3> slots = _carried.get_SlotPosition(anchor, right, ConfigCarry::gap);
     DynArray<CarriedWordNode*> nodes = _carried.reverse_nodes();
 
     // Easing back from the frozen spots: lerp each word from its release
@@ -171,18 +169,12 @@ GameState::pult_DispatchAction(PultAction act)
     _level_director->record_Button(act);
 
     switch (act) {
-        case PultAction::Compile:          on_Compile();               break;
-        case PultAction::RunProgram:       on_Run();                   break;
-        case PultAction::ScrollEditorUp:   Wall::Text::scroll_Up(_ctx.wall_texts, Wall::Role::Editor);
-                                           _editor_wall.scroll(ConfigWall::scroll_step);
-                                                                       break;
-        case PultAction::ScrollEditorDown: Wall::Text::scroll_Down(_ctx.wall_texts, Wall::Role::Editor);
-                                           _editor_wall.scroll(-ConfigWall::scroll_step);
-                                                                       break;
-        case PultAction::ClearEditor:      Wall::Text::clear_ToFloor(_ctx.wall_texts, _ctx.words, Wall::Role::Editor);
-                                           _editor_wall.clear();
-                                                                       break;
-        case PultAction::ConnectEditor:    wallconnect_BeginEditor();  break;
+        case PultAction::Compile:          on_Compile     ();          break;
+        case PultAction::RunProgram:       on_Run         ();          break;
+        case PultAction::ScrollEditorUp:   on_EditorScroll( 1);        break;
+        case PultAction::ScrollEditorDown: on_EditorScroll(-1);        break;
+        case PultAction::ClearEditor:      on_EditorClear ();          break;
+        case PultAction::ConnectEditor:    wallconnect_BeginEditor (); break;
         case PultAction::ConnectProgram:   wallconnect_BeginProgram(); break;
     }
 }
@@ -585,34 +577,34 @@ GameState::handle_Input(FrameInput& input, double dt) {
         }
     }
 
-    if (_wall_connection.get_Target() == WallConnection::Target::Editor) {
-        WallConnection::Request req = _wall_connection.feed_Vim(input.vim_keys, _ctx.camera->pos.y);
+    if (_wall_connection.get_Target() == Wall::Connection::Target::Editor) {
+        Wall::Connection::Request req = _wall_connection.feed_Vim(input.vim_keys, _ctx.camera->pos.y);
         input.vim_keys.clear();
         switch (req) {
-            case WallConnection::Request::Write:
+            case Wall::Connection::Request::Write:
                 editor_CommitToWall(_wall_connection.get_Lines(), false);
                 _wall_connection.mark_Saved();
                 break;
-            case WallConnection::Request::WriteQuit:
+            case Wall::Connection::Request::WriteQuit:
                 editor_CommitToWall(_wall_connection.get_Lines(), true);
                 _wall_connection.end();
                 _frame_input.capture_vim = false;
                 break;
-            case WallConnection::Request::Exit:
+            case Wall::Connection::Request::Exit:
                 editor_CommitToWall(_wall_connection.get_Lines(), true);
                 _wall_connection.end();
                 _frame_input.capture_vim = false;
                 break;
-            case WallConnection::Request::QuitDiscard:
+            case Wall::Connection::Request::QuitDiscard:
                 editor_CommitToWall(_wall_connection.get_Lines(), true);
                 _wall_connection.end();
                 _frame_input.capture_vim = false;
                 break;
-            case WallConnection::Request::None:
+            case Wall::Connection::Request::None:
                 break;
         }
     }
-    else if (_wall_connection.get_Target() == WallConnection::Target::Program) {
+    else if (_wall_connection.get_Target() == Wall::Connection::Target::Program) {
         _wall_connection.set_ProgramBuffer(input.typing_buffer);
     }
     else if (input.typing_mode) {
@@ -802,17 +794,9 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
     { FP_ZONE("render.hint");        _hint_sys    ->draw(renderer, *_ctx.camera, *_ctx.light); }
     { FP_ZONE("render.pult_labels"); _pult_sys    ->draw_Labels(renderer, *_ctx.camera, *_ctx.light); }
     { FP_ZONE("render.particles");   _particle_sys->draw(renderer, *_ctx.camera, *_ctx.light); }
-    { FP_ZONE("render._floor_sys");  _floor_sys   ->draw(renderer, *_ctx.camera, *_ctx.light); }
-
-    { FP_ZONE("render.celebration");
-        _celebration_sys->draw(renderer, *_ctx.camera, *_ctx.light);
-    }
-
-    { FP_ZONE("render.wall_edit");
-        _wall_connection.render(renderer, *_ctx.camera, *_ctx.light);
-    }
-
-    { FP_ZONE("render.end_Frame"); renderer.end_Frame(&window); }
+    { FP_ZONE("render.celebration"); _celebration_sys->draw(renderer, *_ctx.camera, *_ctx.light); }
+    { FP_ZONE("render.wall_edit");   _wall_connection.render(renderer, *_ctx.camera, *_ctx.light); }
+    { FP_ZONE("render.end_Frame");   renderer.end_Frame(&window); }
 }
 
 
@@ -967,7 +951,7 @@ GameState::update_WorldSimulation(double dt)
 void
 GameState::level_TryAdvance(double dt)
 {
-    LevelDirector::AdvanceResult res =
+    Level::Director::AdvanceResult res =
         _level_director->try_Advance(_ctx, dt, *_celebration_sys, *_particle_sys);
 
     if (!res.stepped) return;
@@ -1091,7 +1075,7 @@ GameState::typing_RebuildPreview(const std::string& s)
 {
     typing_ClearPreview();
 
-    const Vec3   anchor    = CarryMath::get_LineAnchor(*_ctx.camera, CarryMath::CARRY_DIST);
+    const Vec3   anchor    = CarriedLine::get_LineAnchor(*_ctx.camera, ConfigCarry::dist);
     const double glyph_yaw = SDL_atan2(-_ctx.camera->fwd.x, -_ctx.camera->fwd.z);
 
     std::istringstream in(s);
@@ -1134,7 +1118,7 @@ GameState::carry_BeginAnim(Text3dPtr word, bool with_pitch)
     anim.t           = 0.0;
     anim.complete    = false;
 
-    const Vec3   anchor    = CarryMath::get_LineAnchor(*_ctx.camera, CarryMath::CARRY_DIST);
+    const Vec3   anchor    = CarriedLine::get_LineAnchor(*_ctx.camera, ConfigCarry::dist);
     const double glyph_yaw = SDL_atan2(-_ctx.camera->fwd.x, -_ctx.camera->fwd.z);
     anim.target_pos = anchor;
     anim.yaw_target = static_cast<float>(glyph_yaw);
@@ -1199,6 +1183,26 @@ GameState::on_WallConnectExit()
 }
 
 
+void
+GameState::on_EditorClear()
+{
+    Wall::Text::clear_ToFloor(_ctx.wall_texts, _ctx.words, Wall::Role::Editor);
+    _editor_wall.clear();
+}
+
+void
+GameState::on_EditorScroll(int8_t dt)
+{
+    if (dt > 0) {
+        Wall::Text::scroll_Up(_ctx.wall_texts, Wall::Role::Editor);
+        _editor_wall.scroll(ConfigWall::scroll_step);
+    }
+    else {
+        Wall::Text::scroll_Down(_ctx.wall_texts, Wall::Role::Editor);
+        _editor_wall.scroll(-ConfigWall::scroll_step);
+    }
+}
+
 void
 GameState::editor_Clear()
 {
diff --git a/src/states/game_state.hpp b/src/states/game_state.hpp
index 096c5e0..573abcf 100644
--- a/src/states/game_state.hpp
+++ b/src/states/game_state.hpp
@@ -3,9 +3,9 @@
 #include "cpputils/unique_ptr.hpp"
 #include "entities/plate.hpp"
 #include "istate.hpp"
+#include "levels/director.hpp"
 #include "levels/goal.hpp"
 #include "levels/scene.hpp"
-#include "rendering/floor_mandelbrot.hpp"
 #include "states/game_context.hpp"
 #include "systems/carry_system.hpp"
 #include "systems/celebration_system.hpp"
@@ -15,7 +15,6 @@
 #include "systems/freelook_system.hpp"
 #include "systems/hint_system.hpp"
 #include "systems/input_system.hpp"
-#include "systems/level_director.hpp"
 #include "systems/particle_system.hpp"
 #include "systems/physics_system.hpp"
 #include "systems/plate_system.hpp"
@@ -26,7 +25,6 @@
 #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 "utils/text.hpp"
 #include "wall/editor.hpp"
@@ -35,24 +33,27 @@
 
 class GameState : public IState
 {
+    cpputils::unique_ptr<InputSystem>        _input_sys;
+    cpputils::unique_ptr<FreelookSystem>     _freelook_sys;
 
-    cpputils::unique_ptr<CelebrationSystem>  _celebration_sys;
     cpputils::unique_ptr<CompilerSystem>     _compiler_sys;
-    cpputils::unique_ptr<CppHighlightSystem> _highlight_sys;
+    cpputils::unique_ptr<ProgramSystem>      _program_sys;
     cpputils::unique_ptr<DynLoaderSystem>    _dyn_loader_sys;
-    cpputils::unique_ptr<FloorMandelbrot>    _floor_sys;
-    cpputils::unique_ptr<FreelookSystem>     _freelook_sys;
+
+
     cpputils::unique_ptr<HintSystem>         _hint_sys;
-    cpputils::unique_ptr<InputSystem>        _input_sys;
-    cpputils::unique_ptr<LevelDirector>      _level_director;
+    cpputils::unique_ptr<Level::Director>      _level_director;
+    cpputils::unique_ptr<CelebrationSystem>  _celebration_sys;
+    cpputils::unique_ptr<PlateSystem>        _plate_sys;
+    cpputils::unique_ptr<PultSystem>         _pult_sys;
+
     cpputils::unique_ptr<ParticleSystem>     _particle_sys;
     cpputils::unique_ptr<PhysicsSystem>      _physics_sys;
-    cpputils::unique_ptr<PlateSystem>        _plate_sys;
-    cpputils::unique_ptr<ProgramSystem>      _program_sys;
     cpputils::unique_ptr<ProjectileSystem>   _projectiles_sys;
-    cpputils::unique_ptr<PultSystem>         _pult_sys;
-    cpputils::unique_ptr<WordAiSystem>       _word_ai_sys;
+    cpputils::unique_ptr<CppHighlightSystem> _highlight_sys;
     cpputils::unique_ptr<WordAnimSystem>     _word_anim_sys;
+    cpputils::unique_ptr<WordAiSystem>       _word_ai_sys;
+
 
     bool     _editor_hidden  { false };
     uint64_t _editor_gen     { 0 };
@@ -62,10 +63,10 @@ class GameState : public IState
     std::string        _typing_last;
 
     CarriedLine        _carried;
-    Wall::Editor         _editor_wall;
+    Wall::Editor       _editor_wall;
     FrameInput         _frame_input;
     GameContext        _ctx;
-    WallConnection     _wall_connection;
+    Wall::Connection     _wall_connection;
     Text::View         _editor_file;
 
 
@@ -78,8 +79,6 @@ class GameState : public IState
     DynArray<WordAnim_Add>  _anims_add;
     DynArray<WordAnim_Drop> _anims_drop;
 
-    void on_WallConnectExit      ();
-    void on_WallConnectSubmit    ();
     void wallconnect_BeginEditor ();
     void wallconnect_BeginProgram();
 
@@ -108,6 +107,10 @@ class GameState : public IState
     void on_TypingCancel (FrameInput&);
     void on_TypingCommit (FrameInput&);
     void on_TypingStart  (FrameInput&);
+    void on_EditorScroll (int8_t dt);
+    void on_EditorClear  ();
+    void on_WallConnectExit  ();
+    void on_WallConnectSubmit();
 
     void carriedline_Update    ();
     void carry_BeginAnim       (Text3dPtr word, bool with_pitch);
diff --git a/src/systems/carry_system.cpp b/src/systems/carry_system.cpp
index ea84023..2f14312 100644
--- a/src/systems/carry_system.cpp
+++ b/src/systems/carry_system.cpp
@@ -1,4 +1,5 @@
 #include "carry_system.hpp"
+#include "entities/camera.hpp"
 
 
 bool
@@ -16,11 +17,11 @@ CarriedLine::push(Text3dPtr w)
 
     if (!first) {
         first = std::move(node);
-        last = first.get();
+        last  = first.get();
     }
     else {
         last->next = std::move(node);
-        last = last->next.get();
+        last       = last->next.get();
     }
     size++;
 }
@@ -65,7 +66,7 @@ CarriedLine::pop_front()
     Text3dPtr word = std::move(first->word);
 
     if (first->next) {
-        first = std::move(first->next);
+        first       = std::move(first->next);
         first->prev = nullptr;
     }
     else {
@@ -82,9 +83,8 @@ DynArray<CarriedWordNode*>
 CarriedLine::nodes() const
 {
     DynArray<CarriedWordNode*> result;
-    for (CarriedWordNode* node = first.get(); node; node = node->next.get()) {
+    for (CarriedWordNode* node = first.get(); node; node = node->next.get())
         result.push_back(node);
-    }
     return result;
 }
 
@@ -93,9 +93,8 @@ DynArray<CarriedWordNode*>
 CarriedLine::reverse_nodes() const
 {
     DynArray<CarriedWordNode*> result;
-    for (CarriedWordNode* node = last; node; node = node->prev) {
+    for (CarriedWordNode* node = last; node; node = node->prev)
         result.push_back(node);
-    }
     return result;
 }
 
@@ -111,7 +110,6 @@ CarriedLine::clear()
 void
 CarriedLine::compact()
 {
-    // Walk and unlink empties. Easiest: rebuild from non-null words.
     DynArray<Text3dPtr> survivors;
     for (CarriedWordNode* node = first.get(); node; node = node->next.get())
         if (node->word) survivors.push_back(std::move(node->word));
@@ -120,3 +118,63 @@ CarriedLine::compact()
     for (Text3dPtr& w : survivors)
         push(std::move(w));
 }
+
+
+
+Vec3
+CarriedLine::get_LineAnchor(const Camera& camera, double carry_dist)
+{
+    const Vec3& right = camera.right;
+    const Vec3  up    = Math::cross(camera.right, camera.fwd);
+
+    return {
+        camera.pos.x + camera.fwd.x * carry_dist
+                     + right.x * ConfigCarry::hand_right - up.x * ConfigCarry::hand_down,
+        camera.pos.y + camera.fwd.y * carry_dist
+                     + right.y * ConfigCarry::hand_right - up.y * ConfigCarry::hand_down,
+        camera.pos.z + camera.fwd.z * carry_dist
+                     + right.z * ConfigCarry::hand_right - up.z * ConfigCarry::hand_down
+    };
+}
+
+
+DynArray<Vec3>
+CarriedLine::get_SlotPosition(const Vec3& anchor,
+                              const Vec3& right,
+                              double carry_gap)
+{
+    DynArray<Vec3> positions;
+    DynArray<CarriedWordNode*> nodes = reverse_nodes();
+
+    if (nodes.empty()) return positions;
+
+    double current_offset = 0.0;
+
+    for (size_t i = 0; i < nodes.size(); i++) {
+        if (!nodes[i]->word) continue;
+
+        positions.push_back({
+            anchor.x + right.x * current_offset,
+            anchor.y + right.y * current_offset,
+            anchor.z + right.z * current_offset
+        });
+
+        if (i + 1 < nodes.size() && nodes[i + 1]->word) {
+            double a = 0.5, b = 0.5;
+
+            if (nodes[i]->word->type == ObjectType::Text)
+                a = static_cast<const Text3d&>(*nodes[i]->word).half_extent;
+            else
+                a = nodes[i]->word->shape.half_extents.x;
+
+            if (nodes[i+1]->word->type == ObjectType::Text)
+                b = static_cast<const Text3d&>(*nodes[i+1]->word).half_extent;
+            else
+                b = nodes[i+1]->word->shape.half_extents.x;
+
+            current_offset -= (a + b + carry_gap);
+        }
+    }
+
+    return positions;
+}
diff --git a/src/systems/carry_system.hpp b/src/systems/carry_system.hpp
index 06e53eb..5ec9995 100644
--- a/src/systems/carry_system.hpp
+++ b/src/systems/carry_system.hpp
@@ -33,4 +33,10 @@ struct CarriedLine
               nodes        () const;
     DynArray<CarriedWordNode*>
               reverse_nodes() const;
+
+    DynArray<Vec3>
+              get_SlotPosition(const Vec3& anchor,
+                               const Vec3& right,
+                               double carry_gap);
+    static Vec3 get_LineAnchor(const Camera& camera, double carry_dist);
 };
diff --git a/src/systems/celebration_system.cpp b/src/systems/celebration_system.cpp
index 2538ae9..ece95f4 100644
--- a/src/systems/celebration_system.cpp
+++ b/src/systems/celebration_system.cpp
@@ -3,6 +3,7 @@
 #include "entities/world.hpp"
 #include "systems/particle_system.hpp"
 
+
 void
 CelebrationSystem::start(const Camera& cam, double& glyph_yaw)
 {
@@ -66,17 +67,16 @@ CelebrationSystem::tick(double dt, ParticleSystem& particle_sys)
         double f = h - i;
         float r=0, g=0, b=0;
         switch (i % 6) {
-            case 0: r=1;     g=(float)f; b=0;          break;
-            case 1: r=(float)(1-f); g=1; b=0;          break;
-            case 2: r=0;     g=1;        b=(float)f;   break;
-            case 3: r=0;     g=(float)(1-f); b=1;      break;
-            case 4: r=(float)f; g=0;     b=1;          break;
+            case 0: r=1;     g=(float)f; b=0;            break;
+            case 1: r=(float)(1-f); g=1; b=0;            break;
+            case 2: r=0;     g=1;        b=(float)f;     break;
+            case 3: r=0;     g=(float)(1-f); b=1;        break;
+            case 4: r=(float)f; g=0;     b=1;            break;
             case 5: r=1;     g=0;        b=(float)(1-f); break;
         }
 
         particle_sys.emit_FireworkBurst(center, SDL_FColor{r, g, b, 1.0f}, 80);
 
-        // Next burst in 0.5..1.0s.
         _next_burst = _t + 0.5 + rnd() * 0.5;
     }
 }
@@ -86,7 +86,7 @@ void
 CelebrationSystem::finish(ParticleSystem& particle_sys)
 {
     _active = false;
-    _banner.reset();
+    _banner.     reset();
     particle_sys.clear();
 }
 
diff --git a/src/systems/hint_system.cpp b/src/systems/hint_system.cpp
index 68eac4e..27e1b9e 100644
--- a/src/systems/hint_system.cpp
+++ b/src/systems/hint_system.cpp
@@ -2,11 +2,12 @@
 #include "systems/hint_system.hpp"
 #include "utils/config.hpp"
 
+
 HintSystem::HintSystem()
 {
-    _label.set_Centre(Vec3{ 0.0, World::get_FloorY<double>() + 2.5, 0.0 });
-    _label.set_Billboard(true);
-    _label.set_Style(ConfigColor::yellow, Text3d::depth_wall, 0.003f);
+    _label.set_Centre    (Vec3{ 0.0, World::get_FloorY<double>() + 2.5, 0.0 });
+    _label.set_Billboard (true);
+    _label.set_Style     (ConfigColor::yellow, Text3d::depth_wall, 0.003f);
     _label.set_Collidable(true);
 }
 
@@ -24,11 +25,14 @@ void
 HintSystem::clear()
 {
     _current_hint.clear();
-    _label.clear();
+    _label.       clear();
 }
 
+
 void
-HintSystem::update(double dt, const Camera& camera) { _label.update(dt, camera); }
+HintSystem::update(double dt, const Camera& camera)
+{ _label.update(dt, camera); }
+
 
 void
 HintSystem::draw(GlRenderer& gl, const Camera& camera, const Light& light) const
diff --git a/src/systems/input_system.cpp b/src/systems/input_system.cpp
index c80a8d4..03a50c6 100644
--- a/src/systems/input_system.cpp
+++ b/src/systems/input_system.cpp
@@ -5,6 +5,7 @@
 #include "states/menu_state.hpp"
 #include "systems/input_system.hpp"
 
+
 void
 InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
 {
@@ -40,8 +41,8 @@ InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
                     }
                 }
                 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);
+                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) {
@@ -92,19 +93,16 @@ 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::TypingStart);     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;
                 case SDLK_PLUS:
                     if (e.key.mod & SDL_KMOD_CTRL)
-                        input.commands.push_back(InputCommand::NumberInc);
-                    break;
+                                { input.commands.push_back(InputCommand::NumberInc); }   break;
                 case SDLK_MINUS:
                     if (e.key.mod & SDL_KMOD_CTRL)
-                        input.commands.push_back(InputCommand::NumberDec);
-                    break;
-
+                                { input.commands.push_back(InputCommand::NumberDec); }   break;
             }
             break;
         }
diff --git a/src/systems/level_runner_system.hpp b/src/systems/level_runner_system.hpp
deleted file mode 100644
index 5f249a5..0000000
--- a/src/systems/level_runner_system.hpp
+++ /dev/null
@@ -1,27 +0,0 @@
-#pragma once
-
-#include "levels/level.hpp"
-
-
-class LevelRunner
-{
-    const Level::Data*
-           _level;
-
-    size_t _current_step{0};
-public:
-    explicit LevelRunner(const Level::Data& _level);
-
-    bool   advance ();
-
-    bool   is_Complete   ()  const { return _current_step >= _level->steps.size(); }
-    bool   is_DynamicLib ()  const { return _level->dynamic_lib; }
-
-    size_t get_Step      ()   const { return _current_step; }
-    const Level::Step&   
-           get_Current   ()   const { return _level->steps[_current_step]; }
-    const std::string&
-           get_SourceFile() const { return _level->source_file; }
-    DynArray<std::string>
-           get_GarantsForStep() const;
-};
diff --git a/src/systems/particle_system.cpp b/src/systems/particle_system.cpp
index dd96552..47fcc45 100644
--- a/src/systems/particle_system.cpp
+++ b/src/systems/particle_system.cpp
@@ -12,7 +12,8 @@ namespace {
 
 // Random point on a unit sphere — used to spread burst velocities evenly.
 // Uses the "two uniform angles" method; fine for fireworks.
-Vec3 random_UnitSphere() {
+Vec3 random_UnitSphere()
+{
     double u    = Random::unit() * 2.0 - 1.0;
     double phi  = Random::unit() * SDL_PI_D * 2.0;
     double s    = SDL_sqrt(1.0 - u * u);
@@ -43,12 +44,12 @@ ParticleSystem::emit_FireworkBurst(const Vec3& center,
         // Slight upward bias so the burst reads as an explosion, not implosion.
         p.vel.y += 1.5;
 
-        p.color = color;
+        p.color    = color;
         p.age      = 0.0;
         p.lifetime = 2.5 + Random::unit() * 1.5;
-        p.yaw  = static_cast<float>(Random::unit() * SDL_PI_D * 2.0);
-        p.spin = static_cast<float>((Random::unit() - 0.5) * 12.0);   // ± 6 rad/s
-        p.alive = true;
+        p.yaw      = static_cast<float>(Random::unit() * SDL_PI_D * 2.0);
+        p.spin     = static_cast<float>((Random::unit() - 0.5) * 12.0);
+        p.alive    = true;
 
         _particles.push_back(p);
     }
@@ -76,7 +77,6 @@ ParticleSystem::update(double dt,
 
         p.yaw += p.spin * static_cast<float>(dt);
 
-        // World bounds — vanish on any wall, floor, or ceiling.
         if (p.pos.y < -ws + 0.05 || p.pos.y >  ws - 0.05 ||
             p.pos.x < -ws + 0.05 || p.pos.x >  ws - 0.05 ||
             p.pos.z < -ws + 0.05 || p.pos.z >  ws - 0.05)
@@ -114,17 +114,17 @@ ParticleSystem::draw(GlRenderer& gl, const Camera& cam, const Light& light) cons
 {
     if (_particles.empty()) return;
 
-    // Glow look: high ambient, low diffuse contribution. Same trick Plate uses.
     Light glow = light;
-    glow.ambient   = { 0.7, 0.7, 0.7 };
+
+    glow.ambient = { 0.7, 0.7, 0.7 };
     glow.sun_intensity        = 0.3f;
     glow.flashlight_intensity = 0.0f;
 
-    Mat4 view = buildViewMat4(cam);
+    Mat4 view = buildViewMat4    (cam);
     Mat4 proj = gl.get_Projection();
 
     gl.set_Wireframe(false);
-    gl.set_Light(glow);
+    gl.set_Light    (glow);
 
     for (const Particle& p : _particles) {
         // Fade out over the last 30% of lifetime.
@@ -138,10 +138,10 @@ ParticleSystem::draw(GlRenderer& gl, const Camera& cam, const Light& light) cons
         Mat4 model = buildModelMat4(p.pos, p.yaw);
         Mat4 mvp   = Mat4::mul(proj, Mat4::mul(view, model));
 
-        gl.set_MVP(model, mvp);
+        gl.set_MVP      (model, mvp);
         gl.set_NormalMat(model);
-        gl.set_Color(p.color.r * fade, p.color.g * fade, p.color.b * fade);
-        gl.draw_Box(PARTICLE_HALF, PARTICLE_HALF, PARTICLE_HALF);
+        gl.set_Color    (p.color.r * fade, p.color.g * fade, p.color.b * fade);
+        gl.draw_Box     (PARTICLE_HALF, PARTICLE_HALF, PARTICLE_HALF);
     }
 }
 
diff --git a/src/systems/physics_system.cpp b/src/systems/physics_system.cpp
index 5d57e11..4ceb678 100644
--- a/src/systems/physics_system.cpp
+++ b/src/systems/physics_system.cpp
@@ -11,9 +11,6 @@
 #include "utils/frame_profiler.hpp"
 
 
-///////////////////////////////////
-/// Player
-
 void
 PhysicsSystem::update_Player(Player& p, double acc_x, double acc_z, double dt)
 {
@@ -144,47 +141,6 @@ PhysicsSystem::resolve_PlayerVsObjects(Player& player, CarriedLine& carried,
 }
 
 
-void
-PhysicsSystem::try_Pair(GameObject* a, GameObject* b)
-{
-    if (!a || !b) return;
-
-    PhysicsMode mi = a->body.mode;
-    PhysicsMode mj = b->body.mode;
-
-    bool i_dyn = (mi == PhysicsMode::Dynamic);
-    bool j_dyn = (mj == PhysicsMode::Dynamic);
-    bool i_hov = (mi == PhysicsMode::Hover);
-    bool j_hov = (mj == PhysicsMode::Hover);
-    bool i_static_solid = (mi == PhysicsMode::Static && a->solid);
-    bool j_static_solid = (mj == PhysicsMode::Static && b->solid);
-
-    if (!i_dyn && !j_dyn && !i_hov && !j_hov) return;
-    if (!Collision::aabb_Overlap(*a, *b))     return;
-
-    if (i_dyn && j_hov) {
-        b->body.mode = PhysicsMode::Dynamic;
-        b->body.vel  = a->body.vel * 0.5;
-    }
-    else if (j_dyn && i_hov) {
-        a->body.mode = PhysicsMode::Dynamic;
-        a->body.vel  = b->body.vel * 0.5;
-    }
-
-    bool i_dyn_now = (a->body.mode == PhysicsMode::Dynamic);
-    bool j_dyn_now = (b->body.mode == PhysicsMode::Dynamic);
-
-    if ( (i_dyn_now && j_dyn_now) ||
-         (i_dyn_now && j_static_solid) ||
-         (j_dyn_now && i_static_solid) ) {
-        Collision::resolve_Objects(*a, *b);
-    }
-}
-
-
-///////////////////////////////////
-/// Objects
-
 void
 PhysicsSystem::update_Objects(const DynArray<GameObject*>& objects,
                               double dt, double gravity)
@@ -240,6 +196,44 @@ PhysicsSystem::update_Objects(const DynArray<GameObject*>& objects,
 }
 
 
+void
+PhysicsSystem::try_Pair(GameObject* a, GameObject* b)
+{
+    if (!a || !b) return;
+
+    PhysicsMode mi = a->body.mode;
+    PhysicsMode mj = b->body.mode;
+
+    bool i_dyn = (mi == PhysicsMode::Dynamic);
+    bool j_dyn = (mj == PhysicsMode::Dynamic);
+    bool i_hov = (mi == PhysicsMode::Hover);
+    bool j_hov = (mj == PhysicsMode::Hover);
+    bool i_static_solid = (mi == PhysicsMode::Static && a->solid);
+    bool j_static_solid = (mj == PhysicsMode::Static && b->solid);
+
+    if (!i_dyn && !j_dyn && !i_hov && !j_hov) return;
+    if (!Collision::aabb_Overlap(*a, *b))     return;
+
+    if (i_dyn && j_hov) {
+        b->body.mode = PhysicsMode::Dynamic;
+        b->body.vel  = a->body.vel * 0.5;
+    }
+    else if (j_dyn && i_hov) {
+        a->body.mode = PhysicsMode::Dynamic;
+        a->body.vel  = b->body.vel * 0.5;
+    }
+
+    bool i_dyn_now = (a->body.mode == PhysicsMode::Dynamic);
+    bool j_dyn_now = (b->body.mode == PhysicsMode::Dynamic);
+
+    if ( (i_dyn_now && j_dyn_now) ||
+         (i_dyn_now && j_static_solid) ||
+         (j_dyn_now && i_static_solid) ) {
+        Collision::resolve_Objects(*a, *b);
+    }
+}
+
+
 void
 PhysicsSystem::resolve_WorldBounds(GameObject& obj)
 {
@@ -269,9 +263,6 @@ PhysicsSystem::resolve_WorldBounds(GameObject& obj)
 }
 
 
-///////////////////////////////////
-/// Projectile
-
 void
 PhysicsSystem::update_Projectiles(DynArray<Projectile>& projectiles, double dt,
                                   const DynArray<Text3dPtr>& words)
@@ -327,11 +318,12 @@ PhysicsSystem::update_Projectiles(DynArray<Projectile>& projectiles, double dt,
 
 
 bool
-PhysicsSystem::resolve_SingleBoxCollision(Projectile& proj,
-    const Vec3& box_min_raw, const Vec3& box_max_raw,
-    const Vec3& word_he, double restitution,
-    Vec3& cur, Vec3& prev, Vec3& dir,
-    Collision::SweptResult& out_sw)
+PhysicsSystem::resolve_SingleBoxCollision(
+        Projectile& proj,
+        const Vec3& box_min_raw, const Vec3& box_max_raw,
+        const Vec3& word_he, double restitution,
+        Vec3& cur, Vec3& prev, Vec3& dir,
+        Collision::SweptResult& out_sw)
 {
     constexpr double EPS = 0.001;
 
@@ -379,7 +371,6 @@ PhysicsSystem::resolve_SingleBoxCollision(Projectile& proj,
 }
 
 
-
 void
 PhysicsSystem::resolve_Projectiles(DynArray<Projectile>& projectiles,
                                    const DynArray<GameObject*>* statics,
@@ -469,7 +460,6 @@ PhysicsSystem::resolve_Projectiles(DynArray<Projectile>& projectiles,
 }
 
 
-
 void
 PhysicsSystem::SpatialGrid::build(const DynArray<GameObject*>& objs)
 {
@@ -493,6 +483,7 @@ PhysicsSystem::SpatialGrid::build(const DynArray<GameObject*>& objs)
     }
 }
 
+
 void
 PhysicsSystem::collide_Grid(const DynArray<GameObject*>& active)
 {
diff --git a/src/systems/plate_system.cpp b/src/systems/plate_system.cpp
index 62ee1e1..9bf928a 100644
--- a/src/systems/plate_system.cpp
+++ b/src/systems/plate_system.cpp
@@ -1,11 +1,13 @@
 #include "entities/player.hpp"
 #include "plate_system.hpp"
 #include "states/game_state.hpp"
+#include "levels/director.hpp"
+
 
 void
 PlateSystem::update(double dt,
                     const Player& player,
-                    const DynArray<StepPlate>& plates)
+                    const DynArray<Level::StepPlate>& plates)
 {
     const double px_min = player.pos.x - static_cast<double>(player.radius);
     const double px_max = player.pos.x + static_cast<double>(player.radius);
@@ -14,7 +16,7 @@ PlateSystem::update(double dt,
     const double pz_min = player.pos.z - static_cast<double>(player.radius);
     const double pz_max = player.pos.z + static_cast<double>(player.radius);
 
-    for (const StepPlate& sp : plates) {
+    for (const Level::StepPlate& sp : plates) {
         Plate* plate = sp.plate;
         if (!plate) continue;
 
@@ -24,9 +26,9 @@ PlateSystem::update(double dt,
         const Vec3 mx = plate->aabb_Max();
 
         const bool xz_overlap = px_max >= mn.x && px_min <= mx.x &&
-                                 pz_max >= mn.z && pz_min <= mx.z;
+                                pz_max >= mn.z && pz_min <= mx.z;
         const bool y_close    = py_min <= mx.y + 0.2 &&
-                                 py_min >= mn.y - 0.1;
+                                py_min >= mn.y - 0.1;
         const bool was_active = plate->active;
         plate->active = xz_overlap && y_close;
 
diff --git a/src/systems/plate_system.hpp b/src/systems/plate_system.hpp
index 599e2ac..a6a8a36 100644
--- a/src/systems/plate_system.hpp
+++ b/src/systems/plate_system.hpp
@@ -3,12 +3,14 @@
 #include "entities/plate.hpp"
 #include "utils/globals.hpp"
 
+
 class  Player;
-struct StepPlate;
+namespace Level { struct StepPlate; }
+
 
 class PlateSystem
 {
 public:
     void update(double dt, const Player& player,
-                const DynArray<StepPlate>& plates);
+                const DynArray<Level::StepPlate>& plates);
 };
diff --git a/src/systems/pult_system.cpp b/src/systems/pult_system.cpp
index 0683ac8..5b957cc 100644
--- a/src/systems/pult_system.cpp
+++ b/src/systems/pult_system.cpp
@@ -1,8 +1,8 @@
 #include "entities/camera.hpp"
 #include "entities/player.hpp"
 #include "pult_system.hpp"
-#include "systems/physics_system.hpp"
 #include "rendering/gl_renderer.hpp"
+#include "systems/physics_system.hpp"
 #include "text_3d/text_label.hpp"
 #include "wall/wall.hpp"
 
@@ -77,8 +77,8 @@ PultSystem::init(DynArray<GameObjectPtr>& scenery,
     _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;
+    add_btn(conne,   "EDIT", PultAction::ConnectEditor,     0.0f, 0.0f);
+    _connect_editor  = conne;
 }
 
 
@@ -112,11 +112,6 @@ PultSystem::handle_ProjectileContact(const ProjectileContactEvent&    ev,
 }
 
 
-// Is the player standing on this button? The button sits on top of the solid
-// pult, so on the ground the player's CENTER is always at least `radius`
-// outside the pult footprint — it can only get over a button by standing on
-// top. So we test the center against the button footprint (small tolerance,
-// NOT expanded by the radius, or brushing the pult's side would trigger it).
 static bool
 player_OnButton(const Player& p, const PultButton& b)
 {
@@ -129,7 +124,6 @@ player_OnButton(const Player& p, const PultButton& b)
     if (std::fabs(p.pos.x - b.pos.x) > hx + TOL) return false;
     if (std::fabs(p.pos.z - b.pos.z) > hz + TOL) return false;
 
-    // On/above the top face, not sailing far overhead.
     const double dy = p.pos.y - (b.pos.y + hy);
     return dy >= -0.10 && dy <= 1.8;
 }
@@ -143,7 +137,6 @@ PultSystem::handle_PlayerContact(const Player&                    player,
     for (PultButton* btn : buttons) {
         const bool over = player_OnButton(player, *btn);
 
-        // Rising edge only: fire once on landing, not every frame stood on it.
         if (over && !btn->player_on) {
             btn->press();
             if (!fired) fired = btn->action;
diff --git a/src/systems/wall_connection.cpp b/src/systems/wall_connection.cpp
index 0484c5a..04f7599 100644
--- a/src/systems/wall_connection.cpp
+++ b/src/systems/wall_connection.cpp
@@ -38,8 +38,12 @@ mono_HalfExtentY()
 }
 
 
+namespace
+Wall
+{
+
 void
-WallConnection::begin_Program(const Vec3& pult_pos)
+Connection::begin_Program(const Vec3& pult_pos)
 {
     _target   = Target::Program;
     _pult_pos = pult_pos;
@@ -48,7 +52,7 @@ WallConnection::begin_Program(const Vec3& pult_pos)
 }
 
 void
-WallConnection::begin_Editor(const Vec3& pult_pos, const DynArray<std::string>& lines, double eye_y)
+Connection::begin_Editor(const Vec3& pult_pos, const DynArray<std::string>& lines, double eye_y)
 {
     _target   = Target::Editor;
     _pult_pos = pult_pos;
@@ -56,7 +60,7 @@ WallConnection::begin_Editor(const Vec3& pult_pos, const DynArray<std::string>&
 }
 
 void
-WallConnection::load_Editor(const DynArray<std::string>& lines, double eye_y)
+Connection::load_Editor(const DynArray<std::string>& lines, double eye_y)
 {
     std::vector<std::string> v(lines.begin(), lines.end());
     _vim.load(v);
@@ -68,7 +72,7 @@ WallConnection::load_Editor(const DynArray<std::string>& lines, double eye_y)
 }
 
 void
-WallConnection::end()
+Connection::end()
 {
     _target = Target::None;
     _prog_input.reset();  _prog_last.clear();
@@ -78,7 +82,7 @@ WallConnection::end()
 
 
 void
-WallConnection::set_ProgramBuffer(const std::string& buffer)
+Connection::set_ProgramBuffer(const std::string& buffer)
 {
     if (buffer == _prog_last) return;
     rebuild_ProgramPreview(buffer);
@@ -86,14 +90,14 @@ WallConnection::set_ProgramBuffer(const std::string& buffer)
 }
 
 void
-WallConnection::clear_ProgramPreview()
+Connection::clear_ProgramPreview()
 {
     _prog_input.reset();
     _prog_last. clear();
 }
 
 void
-WallConnection::rebuild_ProgramPreview(const std::string& buffer)
+Connection::rebuild_ProgramPreview(const std::string& buffer)
 {
     Wall::Transform tf = Wall::get_Transform(Wall::Role::Program, World::get_BoxScale());
     Vec3 pos = tf.position;
@@ -104,8 +108,8 @@ WallConnection::rebuild_ProgramPreview(const std::string& buffer)
 }
 
 
-WallConnection::Request
-WallConnection::feed_Vim(DynArray<int>& keys, double eye_y)
+Connection::Request
+Connection::feed_Vim(DynArray<int>& keys, double eye_y)
 {
     _eye_y = eye_y;
 
@@ -118,11 +122,11 @@ WallConnection::feed_Vim(DynArray<int>& keys, double eye_y)
 
     Request req = Request::None;
     switch (_vim.take_Signal()) {
-        case VimBuffer::Signal::Exit:        return Request::Exit;
-        case VimBuffer::Signal::WriteQuit:   return Request::WriteQuit;
-        case VimBuffer::Signal::QuitDiscard: return Request::QuitDiscard;
-        case VimBuffer::Signal::Write:       req = Request::Write; break;
-        case VimBuffer::Signal::Help:
+        case VimBufferOLD::Signal::Exit:        return Request::Exit;
+        case VimBufferOLD::Signal::WriteQuit:   return Request::WriteQuit;
+        case VimBufferOLD::Signal::QuitDiscard: return Request::QuitDiscard;
+        case VimBufferOLD::Signal::Write:       req = Request::Write; break;
+        case VimBufferOLD::Signal::Help:
             _status.clear();
             _status.push_back("-- VIM HELP --");
             _status.push_back("i a A o O  insert    Esc normal    h j k l move");
@@ -139,8 +143,9 @@ WallConnection::feed_Vim(DynArray<int>& keys, double eye_y)
     return req;
 }
 
+
 DynArray<std::string>
-WallConnection::get_Lines() const
+Connection::get_Lines() const
 {
     DynArray<std::string> out;
     for (const std::string& l : _vim.get_Lines()) out.push_back(l);
@@ -149,7 +154,7 @@ WallConnection::get_Lines() const
 
 
 void
-WallConnection::rebuild_Editor()
+Connection::rebuild_Editor()
 {
     const float ws = World::get_BoxScale();
     Wall::Transform tf = Wall::get_Transform(Wall::Role::Editor, ws);
@@ -316,15 +321,15 @@ WallConnection::rebuild_Editor()
 
 
 void
-WallConnection::rebuild_Cmdline()
+Connection::rebuild_Cmdline()
 {
     _cmdline.reset();
 
     std::string text;
-    if      (_vim.get_Mode() == VimBuffer::Mode::Command) text = _vim.get_Cmdline();
+    if      (_vim.get_Mode() == VimBufferOLD::Mode::Command) text = _vim.get_Cmdline();
     else if (!_status.empty())                            text = _status[0];
     else if (!_vim.get_Status().empty())                  text = _vim.get_Status();
-    else text = (_vim.get_Mode() == VimBuffer::Mode::Insert) ? "-- INSERT --" : "-- NORMAL --";
+    else text = (_vim.get_Mode() == VimBufferOLD::Mode::Insert) ? "-- INSERT --" : "-- NORMAL --";
     if (text.empty()) return;
 
     // Built at origin; render() places it at the eased _cmd_pos each frame.
@@ -333,7 +338,7 @@ WallConnection::rebuild_Cmdline()
 
 
 void
-WallConnection::update(double dt, const Camera& cam, float aspect, float fov_y)
+Connection::update(double dt, const Camera& cam, float aspect, float fov_y)
 {
     if (!is_Active()) return;
     _blink += dt;
@@ -370,7 +375,7 @@ WallConnection::update(double dt, const Camera& cam, float aspect, float fov_y)
 
 
 void
-WallConnection::render(GlRenderer& gl, const Camera& cam, const Light& light) const
+Connection::render(GlRenderer& gl, const Camera& cam, const Light& light) const
 {
     if (_target == Target::Program) {
         if (_prog_input) _prog_input->draw(gl, cam, light);
@@ -403,8 +408,9 @@ WallConnection::render(GlRenderer& gl, const Camera& cam, const Light& light) co
     }
 }
 
+
 void
-WallConnection::draw_ProgramCursor(GlRenderer& gl, const Camera& cam, const Light& light) const
+Connection::draw_ProgramCursor(GlRenderer& gl, const Camera& cam, const Light& light) const
 {
     if (!_prog_input) return;
     if (SDL_fmod(_blink, 0.8) > 0.4) return;
@@ -426,8 +432,9 @@ WallConnection::draw_ProgramCursor(GlRenderer& gl, const Camera& cam, const Ligh
     gl.set_Emissive (0.0f,  0.0f,  0.0f, 0.0f);
 }
 
+
 void
-WallConnection::draw_EditorCursor(GlRenderer& gl, const Camera& cam, const Light& light) const
+Connection::draw_EditorCursor(GlRenderer& gl, const Camera& cam, const Light& light) const
 {
     if (SDL_fmod(_blink, 0.8) > 0.4) return;
 
@@ -459,8 +466,9 @@ WallConnection::draw_EditorCursor(GlRenderer& gl, const Camera& cam, const Light
     gl.set_Emissive (0.0f, 0.0f, 0.0f, 0.0f);
 }
 
+
 void
-WallConnection::draw_Electric(GlRenderer& gl, const Camera& cam) const
+Connection::draw_Electric(GlRenderer& gl, const Camera& cam) const
 {
     const Vec3 a = cam.pos;
     const Vec3 b = _pult_pos;
@@ -492,3 +500,6 @@ WallConnection::draw_Electric(GlRenderer& gl, const Camera& cam) const
     gl.draw_Lines   (pts.data(), static_cast<int>(pts.size()) / 3);
     gl.set_Wireframe(false);
 }
+
+
+} // namespace Wall
diff --git a/src/systems/wall_connection.hpp b/src/systems/wall_connection.hpp
index e1e760b..474fea4 100644
--- a/src/systems/wall_connection.hpp
+++ b/src/systems/wall_connection.hpp
@@ -16,16 +16,18 @@ struct Light;
 struct EditorRow
 {
     DynArray<Text3dPtr>
-           toks;     // per-token meshes, left→right (decreasing z)
-    double left_z   { 0.0 };      // z of the row's left edge (start of char 0)
-    double char_w   { 0.18 };     // mean character width in z (for cursor placement)
-    double he_y     { 0.2 };      // glyph half-height (for cursor vertical offset)
-    double line_x   { 0.0 };      // wall-plane x
+           toks;               // per-token meshes, left→right (decreasing z)
+    double left_z   { 0.0 };   // z of the row's left edge (start of char 0)
+    double char_w   { 0.18 };  // mean character width in z (for cursor placement)
+    double he_y     { 0.2 };   // glyph half-height (for cursor vertical offset)
+    double line_x   { 0.0 };   // wall-plane x
     bool   has_text { false };
 };
 
+namespace Wall
+{
 
-class WallConnection
+class Connection
 {
 public:
     enum class Target  : uint8_t { None, Program, Editor };
@@ -47,21 +49,21 @@ public:
     void    begin_Editor (const Vec3& pult_pos, const DynArray<std::string>& lines, double eye_y);
     void    load_Editor  (const DynArray<std::string>& lines, double eye_y);
 
-    void    mark_Saved() { _vim.mark_Saved(); }
+    void    mark_Saved          () { _vim.mark_Saved(); }
     void    clear_ProgramPreview();
-    Request feed_Vim(DynArray<int>& keys, double eye_y);
+    Request feed_Vim            (DynArray<int>& keys, double eye_y);
 
 
 
 private:
-    bool     _cmd_pos_init { false };
+    bool     _cmd_pos_init     { false };
     unsigned _vim_seen_version { 0 };
 
-    float  _cmd_pitch { 0.0f };
-    float  _cmd_yaw { 0.0f };
-    double _row0_y  { 0.0 };
-    double _eye_y   { 0.0 };
-    double _blink   { 0.0 };
+    float  _cmd_pitch   { 0.0f };
+    float  _cmd_yaw     { 0.0f };
+    double _row0_y      { 0.0 };
+    double _eye_y       { 0.0 };
+    double _blink       { 0.0 };
     double _code_left_z { 0.0 };
 
     Target _target{ Target::None };
@@ -74,7 +76,7 @@ private:
 
     std::string _prog_last;
 
-    VimBuffer _vim;
+    VimBufferOLD _vim;
 
     DynArray<std::string> _cached_line_text;
     DynArray<EditorRow>   _rows;
@@ -88,3 +90,6 @@ private:
     void draw_ProgramCursor(GlRenderer& gl, const Camera& cam, const Light& light) const;
     void draw_Electric     (GlRenderer& gl, const Camera& cam) const;
 };
+
+
+} // namespace Wall
diff --git a/src/systems/word_ai_system.cpp b/src/systems/word_ai_system.cpp
index 5cbc296..1ec2a9f 100644
--- a/src/systems/word_ai_system.cpp
+++ b/src/systems/word_ai_system.cpp
@@ -39,10 +39,9 @@ WordAiSystem::update(DynArray<Text3dPtr>& words,
         Text3dPtr& w = words[i];
         if (!w) continue;
 
-        if (w->ai.state  != Text3dAI::State::Idle &&
-            w->body.mode != PhysicsMode::AiHover &&
-            w->body.mode != PhysicsMode::Hover)
-        {
+        if ( w->ai.state  != Text3dAI::State::Idle &&
+             w->body.mode != PhysicsMode::AiHover &&
+             w->body.mode != PhysicsMode::Hover) {
             for (Text3dPtr& s : w->ai.stolen) {
                 if (!s) continue;
                 s->body.mode = PhysicsMode::Dynamic;
@@ -79,7 +78,6 @@ WordAiSystem::update_Idle(Text3d& w, const Player& p, int active_ai_count, doubl
 
 {
     if (w.body.mode != PhysicsMode::Hover) return;
-
     if (active_ai_count >= _max_active_ai) return;
 
     Vec3 to_player = p.pos - w.pos;
@@ -123,8 +121,8 @@ void WordAiSystem::update_Attack(Text3d& w, const Player& p, CarriedLine& carrie
         };
     }
 
-    Vec3 to_target = target - w.pos;
-    double dist = Math::length(to_target);
+    Vec3   to_target = target - w.pos;
+    double dist      = Math::length(to_target);
 
     w.ai.phase_timer -= dt;
     if (w.ai.phase_timer <= 0.0) {
@@ -365,8 +363,8 @@ WordAiSystem::update_StolenTrail(Text3d& w, double dt)
                             w.body.vel.z * w.body.vel.z);
 
     if (speed > 0.5) {
-        Vec3 raw_back = { -w.body.vel.x / speed, 0.0, -w.body.vel.z / speed };
-        double k = 1.0 - SDL_exp(-3.0 * dt);
+        Vec3   raw_back = { -w.body.vel.x / speed, 0.0, -w.body.vel.z / speed };
+        double k        = 1.0 - SDL_exp(-3.0 * dt);
         w.ai.trail_dir.x += (raw_back.x - w.ai.trail_dir.x) * k;
         w.ai.trail_dir.z += (raw_back.z - w.ai.trail_dir.z) * k;
 
@@ -382,7 +380,7 @@ WordAiSystem::update_StolenTrail(Text3d& w, double dt)
         w.ai.trail_dir = { 1.0, 0.0, 0.0 };
 
     // ── Lerp stolen words toward their slot ─────────────────────────
-    double offset = static_cast<double>(w.half_extent);
+    double offset     = static_cast<double>(w.half_extent);
     double pos_lerp_k = 1.0 - SDL_exp(-8.0 * dt);
 
     Vec3 perp = perpendicular_horizontal(w.ai.trail_dir);
diff --git a/src/systems/word_anim.cpp b/src/systems/word_anim.cpp
index def8a7a..df56a02 100644
--- a/src/systems/word_anim.cpp
+++ b/src/systems/word_anim.cpp
@@ -4,25 +4,28 @@
 #include "word_anim.hpp"
 #include "utils/config.hpp"
 
+
 static constexpr double ANIM_ADD_DUR   = 0.20;
 
+
 static double smoothstep(double t)
 {
     t = std::clamp(t, 0., 1.);
     return t * t * (3. - 2. * t);
 }
 
+
 static double lerp_d(double a, double b, double t) { return a + (b - a) * t; }
 
+
 void
-WordAnimSystem::update(DynArray<WordAnim_Add>&    anims_add,
-                       DynArray<WordAnim_Drop>&   anims_drop,
-                       CarriedLine&                  carried,
-                       DynArray<Text3dPtr>&       words,
-                       double                        dt)
+WordAnimSystem::update(DynArray<WordAnim_Add>&  anims_add,
+                       DynArray<WordAnim_Drop>& anims_drop,
+                       CarriedLine&             carried,
+                       DynArray<Text3dPtr>&     words,
+                       double                   dt)
 {
     double floor_y = World::get_FloorY<double>();
-    // Add animations
     for (WordAnim_Add& anim : anims_add) {
         if (anim.complete) continue;
 
@@ -52,7 +55,6 @@ WordAnimSystem::update(DynArray<WordAnim_Add>&    anims_add,
             [](const WordAnim_Add& a) { return a.complete; }),
         anims_add.end());
 
-    // Drop animations
     for (WordAnim_Drop& drop : anims_drop) {
         if (drop.complete) continue;
 
diff --git a/src/text/handler.cpp b/src/text/handler.cpp
new file mode 100644
index 0000000..f04120d
--- /dev/null
+++ b/src/text/handler.cpp
@@ -0,0 +1,999 @@
+// text/handler.cpp
+//
+// The unified text document. Consolidates four older pieces:
+//   GameState::editor_PlaceLines  -> relayout_Snapped
+//   Wall::Editor (placement/nums) -> resolve_Target / apply_Target / attach_Snapped / maintain_Numbers
+//   Text::View   (lex mapping)    -> render_Source / apply_Highlight
+//   Connection::rebuild_Editor    -> relayout_Mono  (+ shoot-merge attach_Mono)
+//
+// The Handler OWNS its tokens. Snapped is the shoot-and-snap gameplay layout;
+// Mono is the monospace grid vim edits on. Lexing is async: the Handler produces
+// a source string + generation and accepts spans back; GameState owns the thread.
+
+#include <SDL3/SDL.h>
+#include <algorithm>
+#include <sstream>
+#include <string>
+
+#include "cpp_token/cpp_highlight.hpp"
+#include "cpp_token/token_color.hpp"
+#include "entities/camera.hpp"
+#include "entities/world.hpp"
+#include "rendering/gl_renderer.hpp"
+#include "text/handler.hpp"
+#include "utils/config.hpp"
+#include "wall/wall.hpp"            // Wall::Text::tilt_TowardEye, get_Transform
+
+namespace Text
+{
+
+// ── monospace metrics (file-local; from wall_connection.cpp) ──────────────────
+namespace {
+
+double mono_CharWidth()
+{
+    static double w = 0.0;
+    if (w == 0.0) {
+        TokenPtr m1 = make_Token("M",  Token::depth_wall, 0.003f, Vec3{0,0,0});
+        TokenPtr m2 = make_Token("MM", Token::depth_wall, 0.003f, Vec3{0,0,0});
+        w = 2.0 * (double)m2->half_extent - 2.0 * (double)m1->half_extent;
+    }
+    return w;
+}
+
+double mono_HalfExtentY()
+{
+    static double h = 0.0;
+    if (h == 0.0) {
+        TokenPtr m = make_Token("M", Token::depth_wall, 0.003f, Vec3{0,0,0});
+        h = static_cast<double>(m->half_extent_y);
+    }
+    return h;
+}
+
+// Orange line-number token (digit classifies as a literal, so pin it Static).
+TokenPtr make_Number(const std::string& s, const Vec3& pos, float yaw, float scale)
+{
+    TokenPtr t = make_Token(s, Token::depth_wall, scale, pos, ConfigColor::orange);
+    t->yaw = yaw;
+    t->set_Color(ConfigColor::orange);   // -> ColorSource::Static
+    return t;
+}
+
+void set_NumberText(Token* t, const std::string& s)
+{
+    if (t->text == s) return;
+    t->text = s;
+    t->rebuild_Mesh();
+    t->set_Color(ConfigColor::orange);
+}
+
+} // namespace
+
+
+// ── construction / config ─────────────────────────────────────────────────────
+
+Handler::Handler(Wall::Role role, float world_scale, LayoutMode mode)
+    : _role(role), _layout(mode)
+{
+    _tf           = Wall::get_Transform(role, world_scale);
+    _line_height  = ConfigWall::line_height;
+    _word_spacing = ConfigWall::free_space_dist;
+}
+
+void
+Handler::set_Layout(LayoutMode m)
+{
+    if (m == _layout) return;
+    _layout = m;
+    relayout();
+}
+
+void
+Handler::set_ShowNumbers(bool on, bool both_columns)
+{
+    _show_numbers = on;
+    _both_columns = both_columns;
+}
+
+void
+Handler::set_DefaultStyle(float depth, float scale, double line_height, double word_spacing)
+{
+    _depth        = depth;
+    _scale        = scale;
+    _line_height  = line_height;
+    _word_spacing = word_spacing;
+}
+
+
+// ── content ─────────────────────────────────────────────────────────────────
+
+void
+Handler::clear()
+{
+    _lines.clear();
+    _last_src.clear();
+    mark_Dirty();
+}
+
+void
+Handler::set_Lines(const DynArray<std::string>& lines)
+{
+    _lines.clear();
+    const ColorSource src = _lex_enabled ? ColorSource::Lexed : ColorSource::Static;
+
+    for (const std::string& text : lines) {
+        Line ln;
+        ln.line_height  = _line_height;
+        ln.word_spacing = _word_spacing;
+
+        std::istringstream in(text);
+        std::string tk;
+        while (in >> tk) {
+            TokenPtr t = make_Token(tk, _depth, _scale, _tf.position, ConfigColor::wall);
+            t->yaw = _tf.yaw;
+            t->set_ColorSource(src);
+            ln.tokens.push_back(std::move(t));
+        }
+        _lines.push_back(std::move(ln));
+    }
+
+    relayout();
+    mark_Dirty();
+}
+
+void
+Handler::append_Line(const std::string& text)
+{
+    // Scroll existing rows up, drop the new line at the baseline (program wall).
+    for (Line& l : _lines) {
+        l.y += _line_height;
+        for (TokenPtr& t : l.tokens) if (t) t->pos.y += _line_height;
+    }
+
+    Line ln;
+    ln.y = _append_y;
+
+    TokenPtr t = make_Token(text, _depth, _scale, _tf.position, ConfigColor::dark_gray);
+    t->yaw   = _tf.yaw;
+    t->pos   = _tf.position;
+    t->pos.y = _append_y;
+    t->set_Color(ConfigColor::dark_gray);   // Static; program output isn't lexed
+    ln.tokens.push_back(std::move(t));
+
+    _lines.push_back(std::move(ln));
+}
+
+
+// ── ownership transfer ─────────────────────────────────────────────────────────
+
+TokenPtr
+Handler::detach(Token* t)
+{
+    // While editing, the buffer is authoritative — picking a word off the wall
+    // mid-edit would desync it. PickSystem should skip an edit_Active() handler;
+    // refuse here as a backstop.
+    if (edit_Active()) return nullptr;
+
+    for (size_t li = 0; li < _lines.size(); ++li) {
+        Line& l = _lines[li];
+        for (size_t i = 0; i < l.tokens.size(); ++i) {
+            if (l.tokens[i].get() == t) {
+                TokenPtr out = std::move(l.tokens[i]);
+                l.tokens.erase(l.tokens.begin() + static_cast<std::ptrdiff_t>(i));
+                if (l.tokens.empty())
+                    _lines.erase(_lines.begin() + static_cast<std::ptrdiff_t>(li));
+                mark_Dirty();
+                return out;
+            }
+        }
+    }
+    return nullptr;
+}
+
+void
+Handler::attach(DynArray<TokenPtr> tokens, const Vec3& impact)
+{
+    if (tokens.empty()) return;
+    if (_layout == LayoutMode::Mono) attach_Mono   (tokens, impact);
+    else                             attach_Snapped(tokens, impact);
+    mark_Dirty();
+}
+
+void
+Handler::clear_ToFloor(DynArray<TokenPtr>& out)
+{
+    const double hover = World::get_FloorY<double>() + 1.5;
+    for (Line& l : _lines) {
+        for (TokenPtr& t : l.tokens) {
+            if (!t) continue;
+            t->body.mode         = PhysicsMode::Dynamic;
+            t->body.vel          = { 0.0, 1.5, 0.0 };
+            t->body.hover_height = hover;
+            t->set_Color(ConfigColor::floor);   // matches old clear_ToFloor (blue)
+            t->set_Depth(Token::depth_floor);
+            t->set_ColorSource(ColorSource::Lint);   // free word: reclassifies on AI idle
+            out.push_back(std::move(t));
+        }
+    }
+    _lines.clear();
+    mark_Dirty();
+}
+
+
+// ── Snapped placement (ported from Wall::Editor) ───────────────────────────────
+
+Handler::Target
+Handler::resolve_Target(double y) const
+{
+    DynArray<size_t> order;
+    for (size_t i = 0; i < _lines.size(); ++i)
+        if (!_lines[i].is_Empty()) order.push_back(i);
+
+    if (order.empty())
+        return { Target::Insert, 0, y, 0.0 };
+
+    std::sort(order.begin(), order.end(),
+        [&](size_t a, size_t b){ return _lines[a].y > _lines[b].y; });
+
+    auto glyph_top = [&](size_t li) {
+        return _lines[li].y
+             + (double)_lines[li].tokens.front()->half_extent_y
+             + ConfigWall::merge_top_margin;
+    };
+    auto underline = [&](size_t li) { return _lines[li].y - ConfigWall::underline_drop; };
+
+    // 1) Merge: within a line's glyph band.
+    for (size_t li : order)
+        if (y <= glyph_top(li) && y >= underline(li))
+            return { Target::Merge, li, 0.0, 0.0 };
+
+    const double LH = ConfigWall::line_height;
+
+    // 2) Above the top line.
+    {
+        const size_t top = order.front();
+        if (y > glyph_top(top)) {
+            const double min_y = _lines[top].y + LH;
+            const double new_y = (y > min_y) ? y : min_y;
+            return { Target::Insert, top, new_y, 0.0 };
+        }
+    }
+
+    // 3) Between two lines.
+    for (size_t oi = 0; oi + 1 < order.size(); ++oi) {
+        const size_t hi = order[oi];
+        const size_t lo = order[oi + 1];
+        if (y < underline(hi) && y > glyph_top(lo)) {
+            const double hi_y = _lines[hi].y, lo_y = _lines[lo].y;
+            const double gap  = hi_y - lo_y;
+            const double free_top = hi_y - LH;
+            const double free_bot = lo_y + LH;
+
+            if (gap >= 2.0 * LH && y <= free_top && y >= free_bot)
+                return { Target::Insert, lo, y, 0.0 };
+            if (gap >= 2.0 * LH)
+                return { Target::Insert, lo, (y > free_top) ? free_top : free_bot, 0.0 };
+
+            const double deficit = 2.0 * LH - gap;
+            return { Target::Insert, lo, free_top, deficit };
+        }
+    }
+
+    // 4) Below the bottom line.
+    {
+        const size_t bot = order.back();
+        const double max_y = _lines[bot].y - LH;
+        const double new_y = (y < max_y) ? y : max_y;
+        return { Target::Insert, bot, new_y, 0.0 };
+    }
+}
+
+Line&
+Handler::apply_Target(const Target& t)
+{
+    if (t.kind == Target::Merge)
+        return _lines[t.line_index];
+
+    if (t.push_dist > 0.0 && t.line_index < _lines.size()) {
+        const double push_below_y = _lines[t.line_index].y;
+        for (Line& l : _lines) {
+            if (l.y <= push_below_y + 1e-6) {
+                l.y -= t.push_dist;
+                for (TokenPtr& w : l.tokens) if (w) w->pos.y -= t.push_dist;
+                if (l.num_left)  l.num_left ->pos.y -= t.push_dist;
+                if (l.num_right) l.num_right->pos.y -= t.push_dist;
+            }
+        }
+    }
+
+    Line fresh;
+    fresh.y           = t.new_y;
+    fresh.line_height = _line_height;
+    _lines.push_back(std::move(fresh));
+    return _lines.back();
+}
+
+void
+Handler::attach_Snapped(DynArray<TokenPtr>& block, const Vec3& impact)
+{
+    // Order the run visual left->right (decreasing z on the NegX wall).
+    std::sort(block.begin(), block.end(),
+        [](const TokenPtr& a, const TokenPtr& b){
+            if (!a) return false;
+            if (!b) return true;
+            return a->pos.z > b->pos.z;
+        });
+
+    // Incoming editor tokens are lexed; face the wall.
+    for (TokenPtr& w : block) {
+        if (!w) continue;
+        w->yaw = _tf.yaw;
+        w->set_ColorSource(_lex_enabled ? ColorSource::Lexed : ColorSource::Static);
+    }
+
+    double block_w = 0.0;
+    size_t live = 0;
+    for (const TokenPtr& w : block) {
+        if (!w) continue;
+        if (live) block_w += ConfigWall::free_space_dist;
+        block_w += 2.0 * (double)w->half_extent;
+        ++live;
+    }
+    if (live == 0) return;
+
+    Target t      = resolve_Target(impact.y);
+    Line&  target = apply_Target(t);
+
+    auto left_edge  = [](const Token* w){ return w->pos.z + (double)w->half_extent; };
+    auto right_edge = [](const Token* w){ return w->pos.z - (double)w->half_extent; };
+
+    size_t insert_index = 0;
+    for (const TokenPtr& w : target.tokens)
+        if (w && w->pos.z > impact.z) ++insert_index;
+
+    const size_t n = target.tokens.size();
+    double block_left_z = impact.z + block_w * 0.5;
+
+    if (insert_index > 0) {
+        const double max_left = right_edge(target.tokens[insert_index - 1].get())
+                              - ConfigWall::free_space_dist;
+        if (block_left_z > max_left) block_left_z = max_left;
+    }
+    if (insert_index < n) {
+        const double block_right = block_left_z - block_w;
+        const double min_right   = left_edge(target.tokens[insert_index].get())
+                                 + ConfigWall::free_space_dist;
+        if (block_right < min_right) {
+            const double shift = min_right - block_right;
+            for (size_t k = insert_index; k < n; ++k)
+                target.tokens[k]->pos.z -= shift;
+        }
+    }
+
+    // Lay the run out left->right (decreasing z), snap to the row height.
+    double cursor = block_left_z;
+    for (TokenPtr& w : block) {
+        if (!w) continue;
+        const double hw       = (double)w->half_extent;
+        const double center_z = cursor - hw;
+        w->pos.y = target.y;
+        w->pos.z = center_z;
+        w->pos.x = _tf.position.x;
+        cursor   = center_z - hw - ConfigWall::free_space_dist;
+    }
+
+    // Insert into the line at the chosen slot, in visual order, taking ownership.
+    size_t k = 0;
+    for (TokenPtr& w : block) {
+        if (!w) continue;
+        target.tokens.insert(
+            target.tokens.begin() + static_cast<std::ptrdiff_t>(insert_index + k),
+            std::move(w));
+        ++k;
+    }
+    block.clear();
+}
+
+
+// ── Mono shoot-merge ────────────────────────────────────────────────────────────
+
+bool
+Handler::impact_To_RowCol(const Vec3& impact, int& row, int& col) const
+{
+    if (!_vim || _char_w <= 0.0) return false;
+    const auto&  blines = _vim->get_Lines();
+    const int    n      = static_cast<int>(blines.size());
+    if (n == 0) return false;
+
+    int r = static_cast<int>(SDL_lround((_row0_y - impact.y) / _line_height));
+    r = SDL_clamp(r, 0, n);                 // n == one past the end == append a new row
+    if (r == n) { row = n; col = 0; return true; }
+
+    const std::string& line = blines[static_cast<size_t>(r)];
+    int c = static_cast<int>(SDL_lround((_code_left_z - impact.z) / _char_w - 0.5));
+    c = SDL_clamp(c, 0, static_cast<int>(line.size()));
+
+    // Snap to the nearest inter-token gap so the shot never bisects a word.
+    auto is_gap = [&](int i) {
+        return i == 0 || i == (int)line.size()
+            || line[(size_t)i] == ' ' || line[(size_t)(i - 1)] == ' ';
+    };
+    if (!is_gap(c)) {
+        int lo = c, hi = c;
+        while (lo > 0 && !is_gap(lo)) --lo;
+        while (hi < (int)line.size() && !is_gap(hi)) ++hi;
+        c = (c - lo <= hi - c) ? lo : hi;
+    }
+
+    row = r; col = c;
+    return true;
+}
+
+void
+Handler::attach_Mono(DynArray<TokenPtr>& block, const Vec3& impact)
+{
+    if (!_vim) { block.clear(); return; }
+
+    int row, col;
+    if (!impact_To_RowCol(impact, row, col)) { block.clear(); return; }
+
+    std::string word;
+    for (const TokenPtr& w : block) {
+        if (!w) continue;
+        if (!word.empty()) word += ' ';
+        word += w->text;
+    }
+    block.clear();              // meshes dropped; re-projection rebuilds them
+    if (word.empty()) return;
+
+    _vim->insert_Word_At(row, col, word);
+
+    _vim_seen_version = _vim->get_Version();
+    relayout_Mono();
+}
+
+
+// ── async lexing (folds in Text::View) ──────────────────────────────────────────
+
+void
+Handler::mark_Dirty()
+{
+    ++_gen;
+    _dirty = true;
+}
+
+std::string
+Handler::render_Source()
+{
+    std::string out;
+    for (Line& line : _lines) {
+        for (size_t i = 0; i < line.tokens.size(); ++i) {
+            Token* t = line.tokens[i].get();
+            if (!t) continue;
+            if (i) out += ' ';
+            t->byte_begin = out.size();
+            out += t->text;
+            t->byte_end = out.size();
+        }
+        out += '\n';
+    }
+    _last_src = out;
+    _dirty    = false;   // dispatched
+    return out;
+}
+
+void
+Handler::apply_Highlight(const DynArray<HiSpan>& spans, uint64_t gen)
+{
+    if (gen != _gen) return;   // stale result; a newer edit is pending
+
+    for (Line& line : _lines) {
+        for (TokenPtr& tp : line.tokens) {
+            Token* t = tp.get();
+            if (!t || t->color_src != ColorSource::Lexed) continue;
+
+            size_t    best = 0;
+            TokenType type = TokenType::Identifier;
+            bool      found = false;
+            for (const HiSpan& s : spans) {
+                size_t lo = (t->byte_begin > s.byte_begin) ? t->byte_begin : s.byte_begin;
+                size_t hi = (t->byte_end   < s.byte_end)   ? t->byte_end   : s.byte_end;
+                if (lo < hi) {
+                    size_t ov = hi - lo;
+                    if (ov > best) { best = ov; type = s.type; found = true; }
+                }
+            }
+            if (found)
+                t->color = TokenColor::get(type);   // direct: stays ColorSource::Lexed
+        }
+    }
+}
+
+
+// ── queries ─────────────────────────────────────────────────────────────────────
+
+Token*
+Handler::token_At_Byte(size_t off)
+{
+    for (Line& line : _lines)
+        for (TokenPtr& t : line.tokens)
+            if (t && off >= t->byte_begin && off < t->byte_end)
+                return t.get();
+    return nullptr;
+}
+
+DynArray<std::string>
+Handler::as_Lines() const
+{
+    DynArray<std::string> out;
+    out.reserve(_lines.size());
+    for (const Line& line : _lines) {
+        std::string s;
+        for (size_t i = 0; i < line.tokens.size(); ++i) {
+            if (i) s += ' ';
+            if (line.tokens[i]) s += line.tokens[i]->text;
+        }
+        out.push_back(std::move(s));
+    }
+    return out;
+}
+
+
+// ── layout dispatch ─────────────────────────────────────────────────────────────
+
+void
+Handler::relayout()
+{
+    if (_layout == LayoutMode::Mono) relayout_Mono();
+    else                             relayout_Snapped();
+}
+
+void
+Handler::relayout_Snapped()
+{
+    // Bulk top->bottom placement (was GameState::editor_PlaceLines).
+    const double x_wall = _tf.position.x;
+    const double lh     = _line_height;
+    const double gap    = 0.5;
+    const double floor_y = World::get_FloorY<double>();
+
+    size_t n_lines = 0;
+    for (const Line& l : _lines) if (!l.is_Empty()) ++n_lines;
+    if (n_lines == 0) return;
+
+    const double bottom_y = floor_y + 1.2;
+    double y = bottom_y + static_cast<double>(n_lines - 1) * lh;
+
+    double max_width = 0.0;
+    for (Line& l : _lines) {
+        if (l.is_Empty()) continue;
+        double total_w = 0.0;
+        for (TokenPtr& t : l.tokens) {
+            t->yaw  = _tf.yaw;
+            total_w += 2.0 * (double)t->half_extent;
+        }
+        total_w += gap * static_cast<double>(l.tokens.size() - 1);
+        max_width = SDL_max(max_width, total_w);
+        l.y = y;
+        y  -= lh;
+    }
+
+    const double left_z = max_width * 0.5;
+    for (Line& l : _lines) {
+        if (l.is_Empty()) continue;
+        double cursor = left_z;
+        for (TokenPtr& t : l.tokens) {
+            const double half     = (double)t->half_extent;
+            const double center_z = cursor - half;
+            t->pos = Vec3{ x_wall, l.y, center_z };
+            cursor = center_z - half - gap;
+        }
+    }
+}
+
+void
+Handler::relayout_Mono()
+{
+    // Project the vim buffer onto a monospace grid (was Connection::rebuild_Editor),
+    // reusing token meshes for unchanged lines. Coloring is deferred to the async
+    // lex (caller marks dirty); this routine is pure geometry + mesh management.
+    if (!_vim) return;
+
+    const auto&  blines = _vim->get_Lines();
+    const double lh     = _line_height;
+    const int    R      = _vim->get_Row();
+    const int    N      = static_cast<int>(blines.size());
+    const double ws     = World::get_BoxScale();
+    const double bottom_y = -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;
+    _row0_y = SDL_min(anchor_cursor, anchor_bottom);
+
+    _char_w = mono_CharWidth();
+    _he_y   = mono_HalfExtentY();
+
+    // Salvage old rows so unchanged lines keep their meshes (reuse only when the
+    // line COUNT is unchanged — matches the original per-keystroke fast path).
+    DynArray<Line> old = std::move(_lines);
+    _lines.clear();
+    const bool can_reuse = (old.size() == blines.size());
+
+    DynArray<std::string> old_text(old.size());
+    for (size_t i = 0; i < old.size(); ++i) {
+        std::string s;
+        for (size_t j = 0; j < old[i].tokens.size(); ++j) {
+            if (j) s += ' ';
+            if (old[i].tokens[j]) s += old[i].tokens[j]->text;
+        }
+        old_text[i] = std::move(s);
+    }
+
+    // Pass 1: build tokens + gutter numbers; measure widest digit + longest line.
+    struct BT { TokenPtr t; size_t col; size_t len; };
+    struct BR { DynArray<BT> toks; TokenPtr num; };
+    DynArray<BR> built;
+    built.reserve(blines.size());
+
+    double gutter_w = 0.0, block_code_w = 0.0;
+    for (size_t i = 0; i < blines.size(); ++i) {
+        const std::string& s = blines[i];
+        block_code_w = SDL_max(block_code_w, _char_w * static_cast<double>(s.size()));
+
+        BR br;
+        if (can_reuse && i < old.size() && old[i].num_left)
+            br.num = std::move(old[i].num_left);
+        else
+            br.num = make_Number(std::to_string(i + 1), _tf.position, _tf.yaw, 0.0022f);
+
+        if (br.num) {
+            const std::string s_no = std::to_string(i + 1);
+            set_NumberText(br.num.get(), s_no);
+            gutter_w = SDL_max(gutter_w, 2.0 * (double)br.num->half_extent);
+        }
+
+        const bool reuse_line = can_reuse && i < old.size() && old_text[i] == s;
+
+        size_t pos = 0, ti = 0;
+        while (pos < s.size()) {
+            while (pos < s.size() && s[pos] == ' ') ++pos;
+            if (pos >= s.size()) break;
+            const size_t start = pos;
+            std::string  tk;
+            while (pos < s.size() && s[pos] != ' ') { tk += s[pos]; ++pos; }
+
+            TokenPtr t;
+            if (reuse_line && ti < old[i].tokens.size())
+                t = std::move(old[i].tokens[ti]);
+            else {
+                t = make_Token(tk, Token::depth_wall, 0.003f, _tf.position, ConfigColor::white);
+                t->yaw = _tf.yaw;
+                t->set_ColorSource(ColorSource::Lexed);
+            }
+            br.toks.push_back({ std::move(t), start, tk.size() });
+            ++ti;
+        }
+        built.push_back(std::move(br));
+    }
+
+    const double gutter_gap  = 0.6;
+    const double code_u0     = gutter_w + gutter_gap;
+    const double block_w     = code_u0 + block_code_w;
+    const double z_block_left = block_w * 0.5;
+    _code_left_z = z_block_left - code_u0;
+
+    // Pass 2: position numbers + tokens on the grid.
+    for (size_t i = 0; i < built.size(); ++i) {
+        const double row_y = _row0_y - static_cast<double>(i) * lh;
+
+        Line ln;
+        ln.y           = row_y;
+        ln.line_height = lh;
+        ln.number      = static_cast<int>(i) + 1;
+        ln.show_number = true;
+
+        if (built[i].num) {
+            const double he = (double)built[i].num->half_extent;
+            built[i].num->pos   = _tf.position;
+            built[i].num->pos.y = row_y;
+            built[i].num->pos.z = z_block_left - he;
+            ln.num_left = std::move(built[i].num);
+        }
+
+        for (BT& bt : built[i].toks) {
+            const double left_z = _code_left_z - static_cast<double>(bt.col) * _char_w;
+            const double tok_w  = _char_w * static_cast<double>(bt.len);
+            bt.t->pos   = _tf.position;
+            bt.t->pos.y = row_y;
+            bt.t->pos.z = left_z - tok_w * 0.5;
+            ln.tokens.push_back(std::move(bt.t));
+        }
+
+        _lines.push_back(std::move(ln));
+    }
+}
+
+
+// ── line numbers (Snapped) — ported from Wall::Editor::update ──────────────────
+
+void
+Handler::maintain_Numbers(double dt, const Camera& /*cam*/)
+{
+    if (_lines.empty()) return;
+
+    DynArray<size_t> order;
+    order.reserve(_lines.size());
+    for (size_t i = 0; i < _lines.size(); ++i)
+        if (!_lines[i].is_Empty()) order.push_back(i);
+    if (order.empty()) return;
+
+    std::sort(order.begin(), order.end(),
+        [&](size_t a, size_t b){ return _lines[a].y > _lines[b].y; });
+
+    // Global code extents in z, so both columns align with a uniform gap.
+    double code_left = -1e18, code_right = 1e18;
+    for (size_t oi : order)
+        for (TokenPtr& w : _lines[oi].tokens) {
+            if (!w) continue;
+            code_left  = SDL_max(code_left,  w->pos.z + (double)w->half_extent);
+            code_right = SDL_min(code_right, w->pos.z - (double)w->half_extent);
+        }
+
+    double max_lh = 0.0, max_rh = 0.0;
+    int rank = 1;
+    for (size_t oi : order) {
+        Line& l = _lines[oi];
+        const std::string s = std::to_string(rank);
+
+        if (!l.num_left)
+            l.num_left = make_Number(s, Vec3{_tf.position.x, l.y, 0.0}, _tf.yaw,
+                                     ConfigWall::line_number_scale);
+        if (_both_columns && !l.num_right)
+            l.num_right = make_Number(s, Vec3{_tf.position.x, l.y, 0.0}, _tf.yaw,
+                                      ConfigWall::line_number_scale);
+
+        if (l.number != rank) {
+            set_NumberText(l.num_left.get(), s);
+            if (l.num_right) set_NumberText(l.num_right.get(), s);
+            l.number = rank;
+        }
+
+        constexpr double STIFFNESS = 120.0, DAMPING = 12.0;
+        l.bounce_vel += (-STIFFNESS * l.bounce_off - DAMPING * l.bounce_vel) * dt;
+        l.bounce_off += l.bounce_vel * dt;
+
+        max_lh = SDL_max(max_lh, (double)l.num_left->half_extent);
+        if (l.num_right) max_rh = SDL_max(max_rh, (double)l.num_right->half_extent);
+        ++rank;
+    }
+
+    const double ws   = World::get_BoxScale();
+    const double EDGE = 0.3;
+    double left_col_z  = code_left  + ConfigWall::line_number_gap;
+    double right_col_z = code_right - ConfigWall::line_number_gap;
+    left_col_z  = SDL_min(left_col_z,   ws - EDGE - 2.0 * max_lh);
+    right_col_z = SDL_max(right_col_z, -ws + EDGE + 2.0 * max_rh);
+
+    for (size_t oi : order) {
+        Line& l = _lines[oi];
+        double x = _tf.position.x;
+        for (TokenPtr& w : l.tokens) { if (w) { x = w->pos.x; break; } }
+
+        const double ny = l.y + l.bounce_off;
+        l.num_left->pos = Vec3{ x, ny, left_col_z + (double)l.num_left->half_extent };
+        if (l.num_right)
+            l.num_right->pos = Vec3{ x, ny, right_col_z - (double)l.num_right->half_extent };
+    }
+}
+
+void
+Handler::apply_Tilt(const Camera& cam)
+{
+    if (_face == FaceMode::None) return;     // Mono grid stays flat (cursor math)
+
+    // Phase 1 implements the wall case (Pitch toward eye). Yaw/Both (carried,
+    // billboard labels) arrive with the TextLabel fold-in.
+    if (_face != FaceMode::Pitch) return;
+
+    auto tilt = [&](Token* t) {
+        if (t) t->pitch = Wall::Text::tilt_TowardEye(t->pos, cam.pos);
+    };
+    for (Line& l : _lines) {
+        for (TokenPtr& t : l.tokens) tilt(t.get());
+        tilt(l.num_left.get());
+        tilt(l.num_right.get());
+    }
+}
+
+
+// ── per-frame ─────────────────────────────────────────────────────────────────
+
+void
+Handler::update(double dt, const Camera& cam)
+{
+    if (_layout == LayoutMode::Snapped && _show_numbers)
+        maintain_Numbers(dt, cam);
+    apply_Tilt(cam);
+}
+
+void
+Handler::render(GlRenderer& gl, const Camera& cam, const Light& light) const
+{
+    for (const Line& l : _lines) {
+        for (const TokenPtr& t : l.tokens)
+            if (t) t->draw(gl, cam, light);
+        if (_show_numbers) {
+            if (l.num_left)  l.num_left ->draw(gl, cam, light);
+            if (l.num_right) l.num_right->draw(gl, cam, light);
+        }
+    }
+}
+
+void
+Handler::scroll(double delta)
+{
+    for (Line& l : _lines) {
+        l.y += delta;
+        for (TokenPtr& t : l.tokens) if (t) t->pos.y += delta;
+        if (l.num_left)  l.num_left ->pos.y += delta;
+        if (l.num_right) l.num_right->pos.y += delta;
+    }
+}
+
+
+// ── line-number shooting (Snapped) ──────────────────────────────────────────────
+
+DynArray<Handler::NumberHit>
+Handler::number_Hitboxes() const
+{
+    constexpr double PAD     = 0.30;
+    constexpr double DEPTH_X = 0.50;
+
+    DynArray<NumberHit> out;
+    out.reserve(_lines.size() * 2);
+
+    auto push_box = [&](const Token* t, size_t i) {
+        const double hz = (double)t->half_extent   + PAD;
+        const double hy = (double)t->half_extent_y + PAD;
+        const Vec3   c  = t->pos;
+        out.push_back({ HIT_BASE + i,
+                        Vec3{ c.x - DEPTH_X, c.y - hy, c.z - hz },
+                        Vec3{ c.x + DEPTH_X, c.y + hy, c.z + hz } });
+    };
+
+    for (size_t i = 0; i < _lines.size(); ++i) {
+        if (_lines[i].num_left)  push_box(_lines[i].num_left.get(),  i);
+        if (_lines[i].num_right) push_box(_lines[i].num_right.get(), i);
+    }
+    return out;
+}
+
+double
+Handler::scroll_DeltaToEye(size_t line_id, double eye_y) const
+{
+    if (line_id >= _lines.size()) return 0.0;
+    return eye_y - _lines[line_id].y;
+}
+
+void
+Handler::bounce(size_t line_id)
+{
+    if (line_id < _lines.size())
+        _lines[line_id].bounce_vel += ConfigWall::bounce_impulse;
+}
+
+
+// ── Mono cursor support ─────────────────────────────────────────────────────────
+
+Handler::MonoMetrics
+Handler::mono_Metrics() const
+{
+    return { _char_w, _he_y, _code_left_z, _row0_y };
+}
+
+double
+Handler::row_Y(int row) const
+{
+    return _row0_y - static_cast<double>(row) * _line_height;
+}
+
+
+// ── live editing (Mono) ─────────────────────────────────────────────────────────
+
+void
+Handler::begin_Edit(double eye_y)
+{
+    _vim = cpputils::make_unique<VimBuffer>();
+
+    std::vector<std::string> v;
+    for (const std::string& s : as_Lines()) v.push_back(s);
+    _vim->load(v);
+    if (_vim->is_Empty()) _vim->start_Insert();
+    _vim_seen_version = _vim->get_Version();
+
+    _eye_y = eye_y;
+    _face  = FaceMode::None;            // flat grid so the cursor math holds
+    _status.clear();
+
+    _layout = LayoutMode::Mono;
+    relayout_Mono();
+    mark_Dirty();
+}
+
+void
+Handler::end_Edit()
+{
+    DynArray<std::string> lines;
+    if (_vim) for (const std::string& s : _vim->get_Lines()) lines.push_back(s);
+    _vim.reset();
+
+    _face   = FaceMode::Pitch;
+    _layout = LayoutMode::Snapped;
+    set_Lines(lines);                   // rebuilds + relayout_Snapped + mark_Dirty
+}
+
+Handler::EditReq
+Handler::feed_Keys(DynArray<int>& keys, double eye_y)
+{
+    _eye_y = eye_y;
+    if (!_vim) return EditReq::None;
+
+    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));
+    }
+
+    EditReq req = EditReq::None;
+    switch (_vim->take_Signal()) {
+        case VimBuffer::Signal::Exit:        return EditReq::Exit;
+        case VimBuffer::Signal::WriteQuit:   return EditReq::WriteQuit;
+        case VimBuffer::Signal::QuitDiscard: return EditReq::Discard;
+        case VimBuffer::Signal::Write:       req = EditReq::Write; break;
+        case VimBuffer::Signal::Help:
+            _status = {
+                "-- VIM HELP --",
+                "i a A o O  insert    Esc normal    h j k l move",
+                "0 $  line ends   gg G :N  jump line   x dd  delete",
+                ":w write   :q quit   :wq write+quit   :q! discard",
+            };
+            break;
+        default: break;
+    }
+
+    if (_vim->get_Version() != _vim_seen_version) {
+        _vim_seen_version = _vim->get_Version();
+        relayout_Mono();
+        mark_Dirty();
+    }
+    return req;
+}
+
+void
+Handler::mark_Saved()
+{
+    if (_vim) _vim->mark_Saved();
+}
+
+int
+Handler::cursor_Row() const { return _vim ? _vim->get_Row() : 0; }
+
+int
+Handler::cursor_Col() const { return _vim ? _vim->get_Col() : 0; }
+
+VimBuffer::Mode
+Handler::cursor_Mode() const { return _vim ? _vim->get_Mode() : VimBuffer::Mode::Normal; }
+
+const std::string&
+Handler::cmdline_Text() const
+{
+    static const std::string empty;
+    return _vim ? _vim->get_Cmdline() : empty;
+}
+
+const DynArray<std::string>&
+Handler::status_Lines() const { return _status; }
+
+} // namespace Text
diff --git a/src/text/handler.hpp b/src/text/handler.hpp
new file mode 100644
index 0000000..8736ca2
--- /dev/null
+++ b/src/text/handler.hpp
@@ -0,0 +1,206 @@
+#pragma once
+//
+// text/handler.hpp  —  Phase 1 contract
+//
+// Text::Handler is the document: the single source of truth for one wall's text.
+// It OWNS its lines/tokens (no more reconstruct-by-sorting in extract_Text, no
+// more parallel Wall::Editor / Text::View / EditorRow). It does layout, async
+// lexing, rotation-to-eye, line numbers, scrolling, render, and the ownership
+// transfer (detach/attach) that the pick/projectile mechanics ride on.
+//
+// One Handler per wall role. Per the agreed split, role-SPECIFIC behavior stays
+// in the systems (program_system feeds lines, compiler writes output,
+// show_TokenInfo writes info rows); the Handler is the layout/lex/render engine
+// those systems push content into.
+//
+// Editor + vim are TWO LAYOUTS of the same lines, never simultaneous:
+//   LayoutMode::Snapped — shoot-and-snap placement (resolve_Target/place_OnLine).
+//   LayoutMode::Mono     — monospace grid; what vim edits on.
+// Switching modes repositions tokens and rebuilds meshes only for changed text,
+// killing the current editor_Clear()+editor_PlaceLines() thrash.
+//
+
+#include <cstdint>
+#include <string>
+
+#include "cpp_token/cpp_highlight.hpp"   // HiSpan
+#include "entities/camera.hpp"
+#include "text/line.hpp"
+#include "text/token.hpp"
+#include "utils/globals.hpp"
+#include "utils/light.hpp"
+#include "vim_buffer.hpp"        // VimBuffer (owned in Mono mode)
+#include "wall/wall.hpp"                 // Wall::Role, Wall::Transform, Wall::Position
+
+class GlRenderer;
+
+namespace Text
+{
+
+enum class LayoutMode : uint8_t { Snapped, Mono };
+
+class Handler
+{
+public:
+    // Line-number hitboxes for shooting numbers (was Wall::Editor::NumberHit).
+    // The top bit distinguishes a number hit from a regular token index when a
+    // single id space is shared with the projectile system.
+    static constexpr size_t HIT_BASE = size_t(1) << 32;
+    static bool   is_NumberHit(size_t idx) { return idx >= HIT_BASE; }
+    static size_t line_Of     (size_t idx) { return idx - HIT_BASE; }
+
+    struct NumberHit { size_t index; Vec3 aabb_Min; Vec3 aabb_Max; };
+
+    struct MonoMetrics {                 // fed to the vim cursor renderer
+        double char_w;
+        double he_y;
+        double left_z;                   // z of column 0's left edge
+        double row0_y;
+    };
+
+
+    Handler() = default;
+    Handler(Wall::Role role, float world_scale, LayoutMode mode);
+
+    // ── configuration / render options ────────────────────────────────────
+    void set_Layout      (LayoutMode m);              // switch + reposition (no full rebuild)
+    void set_FaceMode    (FaceMode f)  { _face = f; }
+    void set_Lexing      (bool on)     { _lex_enabled = on; }   // info/compiler/program off
+    void set_ShowNumbers (bool on, bool both_columns = false);
+    void set_Bordered    (bool on)     { _bordered  = on; }
+    void set_Underline   (bool on)     { _underline = on; }
+    void set_DefaultStyle(float depth, float scale, double line_height, double word_spacing);
+
+    Wall::Role role  () const { return _role; }
+    LayoutMode layout() const { return _layout; }
+
+    // ── content ────────────────────────────────────────────────────────────
+    void clear();
+    void set_Lines  (const DynArray<std::string>& lines);  // (re)build from text; relayouts + marks dirty
+    void append_Line(const std::string& text);             // program output: scroll up, add at bottom
+    void set_AppendBaseline(double y) { _append_y = y; }   // bottom row y for append_Line (program wall)
+
+    // ── ownership transfer (the heart of the rewrite) ─────────────────────
+    // detach: remove a token from its line, hand ownership out (pick / shoot-off).
+    //         empties + drops the line if it was the last token. Marks dirty.
+    // attach: take ownership of a token and place it.
+    //   Snapped -> resolve_Target + place_OnLine (fuses today's on_LinePlaced +
+    //              Wall::Text::from_Drop).
+    //   Mono    -> SHOOT-MERGE. impact maps to (row,col) via impact_To_RowCol;
+    //              col snaps to the nearest inter-token gap (never bisects a
+    //              word); the token's text is spliced into the edit buffer
+    //              (VimBuffer::insert_Word_At) so it survives the next keystroke.
+    //              The incoming mesh is dropped; re-projection rebuilds it.
+    TokenPtr detach(Token* t);
+    void     attach(DynArray<TokenPtr> tokens, const Vec3& impact);   // a shot run, placed as one block
+    void     clear_ToFloor(DynArray<TokenPtr>& out);       // was Wall::Text::clear_ToFloor
+
+    // ── async lexing (pull model; GameState owns the CppHighlightSystem) ──
+    // Loop:  if (h.is_Dirty()) hl.request(h.render_Source(), h.generation());
+    //        if (hl.poll(spans, gen)) h.apply_Highlight(spans, gen);
+    // render_Source assigns byte_begin/byte_end across all tokens (was
+    // Text::View::render_Source); apply_Highlight is the best-overlap span match
+    // (was Text::View::apply_Spans) and ignores stale generations.
+    bool        is_Dirty   () const { return _lex_enabled && _dirty; }
+    uint64_t    generation () const { return _gen; }
+    std::string render_Source();
+    void        apply_Highlight(const DynArray<HiSpan>& spans, uint64_t gen);
+    void        mark_Dirty ();
+
+    // ── queries (for PickSystem + projectile landing) ─────────────────────
+    // PickSystem walks get_Lines() across all Handlers, tracking the best
+    // {Handler*, Token*}; PickResult carries those instead of a wall index.
+    const DynArray<Line>& get_Lines() const { return _lines; }
+    Token*  token_At_Byte (size_t off);
+    DynArray<std::string> as_Lines() const;                // for vim load + file sync
+
+    // ── per-frame ──────────────────────────────────────────────────────────
+    void update(double dt, const Camera& cam);   // bounce springs, tilt-to-eye, number placement
+    void render(GlRenderer& gl, const Camera& cam, const Light& light) const;
+    void scroll(double delta);                    // shift every row + its tokens
+
+    // ── line-number shooting (Snapped/editor) ─────────────────────────────
+    DynArray<NumberHit> number_Hitboxes  () const;
+    double              scroll_DeltaToEye (size_t line_id, double eye_y) const;
+    void                bounce            (size_t line_id);
+
+    // ── vim cursor support (Mono) ──────────────────────────────────────────
+    MonoMetrics mono_Metrics() const;
+    double      row_Y       (int row) const;
+
+    // ── live editing (Mono) — Handler owns the VimBuffer ──────────────────
+    // begin_Edit builds the buffer from the current lines and switches to Mono;
+    // Wall::Connection becomes a thin overlay that forwards keys via feed_Keys
+    // and reads cursor_Row/Col/Mode for the cursor box + cmdline. The Write /
+    // WriteQuit / Exit / Discard request that GameState acts on (file sync, end
+    // connection) surfaces from feed_Keys.
+    enum class EditReq : uint8_t { None, Write, WriteQuit, Exit, Discard };
+
+    void    begin_Edit (double eye_y);
+    void    end_Edit   ();
+    bool    edit_Active() const { return _layout == LayoutMode::Mono && _vim.get() != nullptr; }
+    EditReq feed_Keys  (DynArray<int>& keys, double eye_y);   // drives vim, re-projects, re-lexes
+    void    mark_Saved ();
+
+    int                          cursor_Row    () const;
+    int                          cursor_Col    () const;
+    VimBuffer::Mode              cursor_Mode    () const;
+    const std::string&           cmdline_Text   () const;
+    const DynArray<std::string>& status_Lines   () const;
+
+private:
+    DynArray<Line> _lines;                 // single source of truth
+
+    Wall::Role      _role   { Wall::Role::Editor };
+    Wall::Transform _tf     {};            // cached world placement (pos + yaw)
+    LayoutMode      _layout { LayoutMode::Snapped };
+    FaceMode        _face   { FaceMode::Pitch };
+
+    // style defaults (per-line may override)
+    float  _depth        { Token::depth_wall };
+    float  _scale        { 0.003f };
+    double _line_height  { 0.0 };
+    double _word_spacing { 0.0 };
+    double _append_y     { 0.0 };   // program-wall bottom row
+
+    DynArray<std::string> _status;  // multi-line status (vim :help etc.), for the overlay
+
+    bool _lex_enabled  { true };
+    bool _show_numbers { false };
+    bool _both_columns { false };
+    bool _bordered     { false };
+    bool _underline    { false };
+
+    // lexing
+    bool        _dirty { false };
+    uint64_t    _gen   { 0 };
+    std::string _last_src;
+
+    // Mono grid cache (vim layout + cursor)
+    double _char_w     { 0.0 };
+    double _he_y       { 0.0 };
+    double _code_left_z{ 0.0 };
+    double _row0_y     { 0.0 };
+    double _eye_y      { 0.0 };
+
+    // Edit model — present only while editing in Mono mode (the editor Handler).
+    cpputils::unique_ptr<VimBuffer> _vim;
+    unsigned                        _vim_seen_version { 0 };
+
+    // layout
+    void   relayout       ();              // dispatch on _layout
+    void   relayout_Snapped();
+    void   relayout_Mono   ();
+    // Snapped placement internals (lifted from Wall::Editor)
+    struct Target { enum Kind { Merge, Insert } kind; size_t line_index; double new_y; double push_dist; };
+    Target resolve_Target (double y) const;
+    Line&  apply_Target   (const Target& t);
+    void   attach_Snapped (DynArray<TokenPtr>& block, const Vec3& impact);
+    void   attach_Mono    (DynArray<TokenPtr>& block, const Vec3& impact);
+    void   maintain_Numbers(double dt, const Camera& cam);   // Snapped: rank + bounce + place
+    void   apply_Tilt     (const Camera& cam);               // FaceMode pitch toward eye
+    // Mono shoot-merge: world impact -> buffer (row,col), col snapped to a gap.
+    bool   impact_To_RowCol(const Vec3& impact, int& row, int& col) const;
+};
+
+} // namespace Text
diff --git a/src/text/line.cpp b/src/text/line.cpp
new file mode 100644
index 0000000..b7e8edb
--- /dev/null
+++ b/src/text/line.cpp
@@ -0,0 +1,40 @@
+// text/line.cpp
+
+#include "text/line.hpp"
+
+namespace Text
+{
+
+double
+Line::width(double gap) const
+{
+    if (tokens.empty()) return 0.0;
+    double w = 0.0;
+    for (const TokenPtr& t : tokens)
+        if (t) w += 2.0 * static_cast<double>(t->half_extent);
+    w += gap * static_cast<double>(tokens.size() - 1);
+    return w;
+}
+
+Token*
+Line::token_At_Byte(size_t off) const
+{
+    for (const TokenPtr& t : tokens)
+        if (t && off >= t->byte_begin && off < t->byte_end)
+            return t.get();
+    return nullptr;
+}
+
+void
+Line::clear()
+{
+    tokens.clear();
+    num_left.reset();
+    num_right.reset();
+    number      = -1;
+    show_number = false;
+    bounce_off  = 0.0;
+    bounce_vel  = 0.0;
+}
+
+} // namespace Text
diff --git a/src/text/line.hpp b/src/text/line.hpp
new file mode 100644
index 0000000..8662e90
--- /dev/null
+++ b/src/text/line.hpp
@@ -0,0 +1,55 @@
+#pragma once
+//
+// text/line.hpp  —  Phase 1 contract
+//
+// Text::Line is the single owning line structure. It replaces three things that
+// each modeled "a row of tokens" for one context:
+//   - Wall::Editor::Line   (non-owning Text3d*, line numbers, bounce)
+//   - Text::Line/Token     (logical row for tree-sitter, non-owning view ptr)
+//   - EditorRow            (vim mode's parallel owned meshes)
+//
+// A Line OWNS its tokens. Layout that needs cross-line context (snapping,
+// pushing neighbors, column alignment) lives on the Handler, not here — Line is
+// mostly data plus a few conveniences. `y` is the row's world height; tokens on
+// the row share it.
+//
+
+#include "text/token.hpp"
+#include "utils/globals.hpp"
+
+namespace Text
+{
+
+struct Line
+{
+    DynArray<TokenPtr> tokens;       // OWNS the row's word meshes, visual order
+
+    double y            { 0.0 };     // world-space row height (shared by tokens)
+    double line_height  { 0.0 };     // spacing to the next row (0 = use Handler default)
+    double word_spacing { 0.0 };     // gap between tokens (0 = use Handler default)
+
+    // Line numbering. The Handler decides numbering policy (one vs two columns,
+    // visibility); the meshes + per-row bounce spring live here because the
+    // bounce is driven per-line when a number is shot (Wall::Editor::bounce).
+    int      number      { -1 };     // 1-based; -1 = unassigned
+    bool     show_number { false };
+    double   bounce_off  { 0.0 };
+    double   bounce_vel  { 0.0 };
+    TokenPtr num_left;               // left gutter digit (always, when shown)
+    TokenPtr num_right;              // right column digit (editor's both_columns only)
+
+    bool   is_Empty() const { return tokens.empty(); }
+    size_t count   () const { return tokens.size(); }
+
+    // Total visual width = sum of glyph widths + internal gaps. Used by Handler
+    // layout (block centering, snap). gap defaults to the line/Handler spacing.
+    double width(double gap) const;
+
+    // Find the owned token covering a byte offset in the Handler's rendered
+    // source (byte_begin/byte_end are assigned by Handler::render_Source).
+    Token* token_At_Byte(size_t off) const;
+
+    void clear();   // drops tokens + number meshes, resets bounce
+};
+
+} // namespace Text
diff --git a/src/text/token.cpp b/src/text/token.cpp
new file mode 100644
index 0000000..65e383e
--- /dev/null
+++ b/src/text/token.cpp
@@ -0,0 +1,377 @@
+// text/token.cpp
+//
+// Port of text_3d.cpp onto Text::Token. Mesh build, GPU caching, carry-follow
+// update, and the AI sparkle tail are carried over unchanged. The only behavior
+// changes are in color routing (ColorSource instead of the set_Look sentinel)
+// and AI ownership (optional component, constructed by enable_Ai()).
+
+#include <SDL3/SDL.h>
+
+#include "cpp_token/token_color.hpp"
+#include "entities/camera.hpp"
+#include "entities/world.hpp"
+#include "rendering/gl_renderer.hpp"
+#include "systems/cpp_highlight_system.hpp"   // CppHighlight::classify_Word
+#include "text/token.hpp"
+#include "utils/globals.hpp"
+
+namespace Text
+{
+
+// ── sparkle-tail helpers (file-local; unchanged from text_3d.cpp) ─────────────
+
+static SDL_FColor
+aiGlintColor(Token::Ai::State s)
+{
+    switch (s) {
+        case Token::Ai::State::Attack:  return {1.0f, 0.85f, 0.75f, 1.0f};
+        case Token::Ai::State::Flee:    return {1.0f, 1.0f,  0.80f, 1.0f};
+        case Token::Ai::State::Stunned: return {0.85f, 0.85f, 0.95f, 1.0f};
+        case Token::Ai::State::Idle:    return {1.0f, 1.0f,  1.0f,  1.0f};
+    }
+    return {1.0f, 1.0f, 1.0f, 1.0f};
+}
+
+static Vec3 v_norm(const Vec3& v)
+{
+    double l = SDL_sqrt(v.x*v.x + v.y*v.y + v.z*v.z);
+    return (l < 1e-9) ? Vec3{0,0,1} : Vec3{v.x/l, v.y/l, v.z/l};
+}
+
+// Camera-facing filled disc (triangle fan). Same vertex layout as the text mesh.
+static void
+append_disc(DynArray<float>& v, DynArray<int>& idx,
+            const Vec3& c, const Vec3& right, const Vec3& up,
+            const Vec3& camN, double radius)
+{
+    constexpr int SEG = 10;
+    const int base = static_cast<int>(v.size()) / 9;
+
+    auto push = [&](const Vec3& p) {
+        v.push_back((float)p.x);    v.push_back((float)p.y);    v.push_back((float)p.z);
+        v.push_back((float)camN.x); v.push_back((float)camN.y); v.push_back((float)camN.z);
+        v.push_back(1.0f);          v.push_back(0.0f);          v.push_back(0.0f);
+    };
+
+    push(c);
+    for (int i = 0; i < SEG; ++i) {
+        double ang = static_cast<double>(i) * (2.0 * SDL_PI_D / SEG);
+        double dx = SDL_cos(ang) * radius, dy = SDL_sin(ang) * radius;
+        push(Vec3{ c.x + right.x*dx + up.x*dy,
+                   c.y + right.y*dx + up.y*dy,
+                   c.z + right.z*dx + up.z*dy });
+    }
+    for (int i = 0; i < SEG; ++i) {
+        idx.push_back(base + 0);
+        idx.push_back(base + 1 + i);
+        idx.push_back(base + 1 + ((i + 1) % SEG));
+    }
+}
+
+// Fast integer hash → float in [0,1). Used for all per-sparkle randomness.
+static double hash01(uint32_t x)
+{
+    x ^= x >> 16; x *= 0x7feb352dU;
+    x ^= x >> 15; x *= 0x846ca68bU;
+    x ^= x >> 16;
+    return (x & 0x00ffffffU) / static_cast<double>(0x01000000);
+}
+
+static SDL_FColor mix_col(SDL_FColor a, SDL_FColor b, float k)
+{
+    return { a.r + (b.r - a.r) * k,
+             a.g + (b.g - a.g) * k,
+             a.b + (b.b - a.b) * k, 1.0f };
+}
+
+
+// ── construction / lifetime ───────────────────────────────────────────────────
+
+Token::Token(const std::string& txt,
+             float dpth, float scle,
+             Vec3 position, SDL_FColor colr)
+     : GameObject(position, ObjectType::Text,
+                  CollisionShape{}, RigidBody{PhysicsMode::Hover},
+                  true, true, true),
+       text(txt), color(colr), depth(dpth), scale(scle)
+{
+    // Lone-token colour (Lint). Editor tokens get switched to Lexed by the Handler.
+    set_Color(TokenColor::get(CppHighlight::classify_Word(text)));
+    rebuild_Mesh();
+
+    body.hover_phase = Random::unit() * SDL_PI_D * 2.0;
+
+    // AI personality (by value, like the old Text3d ctor).
+    ai.aggressiveness = Random::unit();
+    ai.wiggle_phase   = Random::unit() * SDL_PI_D * 2.0;
+    ai.wiggle_freq    = 3.0 + Random::unit() * 4.0;   // 3–7 rad/s
+    ai.speed          = 2.5 + Random::unit() * 2.5;
+}
+
+
+Token::~Token()
+{
+    // Renderer owns the GL context; only delete if buffers were created.
+    if (gpu_vbo || gpu_ebo)
+        GlRenderer::free_MeshBuffers(gpu_vbo, gpu_ebo);
+    if (gpu_vbo_sparkle || gpu_ebo_sparkle)
+        GlRenderer::free_MeshBuffers(gpu_vbo_sparkle, gpu_ebo_sparkle);
+}
+
+
+// ── color ────────────────────────────────────────────────────────────────────
+
+void
+Token::set_Color(SDL_FColor c)
+{
+    color = c;                         // direct; color_src unchanged (old semantics)
+}
+
+void
+Token::set_Look(SDL_FColor c, float new_depth)
+{
+    // Legacy shim. ConfigColor::keep == {0,-1,0,1} hits the g==-1 "keep" branch.
+    if      (c.r == -1.0f) color = TokenColor::get(CppHighlight::classify_Word(text));
+    else if (c.g == -1.0f) { /* keep current colour */ }
+    else                   color = c;
+    set_Depth(new_depth);
+}
+
+void
+Token::recolor()
+{
+    // Lexed → coloured by Handler::apply_Highlight; Static → pinned; Lint → reclassify.
+    if (color_src == ColorSource::Lint)
+        color = TokenColor::get(CppHighlight::classify_Word(text));
+}
+
+
+void
+Token::set_Depth(float new_depth)
+{
+    if (new_depth == depth) return;
+    depth = new_depth;
+    rebuild_Mesh();                    // never touches color
+}
+
+
+// ── misc ───────────────────────────────────────────────────────────────────────
+
+Vec3
+Token::words_centroid(const DynArray<TokenPtr>& words)
+{
+    Vec3   c{};
+    double n = 0.0;
+    for (const TokenPtr& w : words) {
+        if (!w) continue;
+        c += w->pos;
+        n += 1.0;
+    }
+    if (n > 0.0) c = c / n;
+    return c;
+}
+
+
+Vec3
+Token::get_Distance(Vec3 position) const
+{
+    return pos - position;
+}
+
+
+void
+Token::rebuild_Mesh()
+{
+    loaded = mesh.build(font_path, text, 64, depth, scale);
+    if (!loaded) {
+        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
+                     "Text::Token: mesh build failed for \"%s\"", text.c_str());
+        return;
+    }
+
+    float minX = 1e9f, maxX = -1e9f;
+    float minY = 1e9f, maxY = -1e9f;
+    for (const Vec3& v : mesh.verts) {
+        float vx = static_cast<float>(v.x);
+        float vy = static_cast<float>(v.y);
+        if (vx < minX) minX = vx;
+        if (vx > maxX) maxX = vx;
+        if (vy < minY) minY = vy;
+        if (vy > maxY) maxY = vy;
+    }
+    half_extent   = (maxX - minX) * 0.5f;
+    half_extent_y = (maxY - minY) * 0.5f;
+
+    shape.half_extents = {
+        static_cast<double>(half_extent),
+        static_cast<double>(half_extent_y),
+        static_cast<double>(depth) * 0.5
+    };
+
+    // Pack the GPU-ready interleaved buffer once. draw() just uploads/binds it.
+    packed_verts.clear();
+    packed_indices.clear();
+    const size_t nv = mesh.verts.size();
+    packed_verts.reserve(nv * 9);
+    for (size_t i = 0; i < nv; i++) {
+        const Vec3& v = mesh.verts[i];
+        const Vec3& n = mesh.vertex_normals[i];
+        const float f = (i < mesh.is_front.size()) ? mesh.is_front[i] : 0.0f;
+        packed_verts.push_back((float)v.x); packed_verts.push_back((float)v.y); packed_verts.push_back((float)v.z);
+        packed_verts.push_back((float)n.x); packed_verts.push_back((float)n.y); packed_verts.push_back((float)n.z);
+        packed_verts.push_back(f);          packed_verts.push_back(0.0f);       packed_verts.push_back(0.0f);
+    }
+    packed_indices.reserve(mesh.tris.size() * 3);
+    for (const MeshTri& t : mesh.tris) {
+        packed_indices.push_back(t.a);
+        packed_indices.push_back(t.b);
+        packed_indices.push_back(t.c);
+    }
+    gpu_dirty = true;   // mesh changed → re-upload on next draw
+}
+
+
+void
+Token::update(const Vec3& camera_pos, const double view_mat[9], float carry_dist)
+{
+    float fx = -static_cast<float>(view_mat[6]);
+    float fy = -static_cast<float>(view_mat[7]);
+    float fz = -static_cast<float>(view_mat[8]);
+    pos.x = camera_pos.x + static_cast<double>(fx * carry_dist);
+    pos.y = camera_pos.y + static_cast<double>(fy * carry_dist);
+    pos.z = camera_pos.z + static_cast<double>(fz * carry_dist);
+    yaw = SDL_atan2f(-fx, -fz);
+}
+
+
+// ── draw (word mesh + AI sparkle tail) ─────────────────────────────────────────
+
+void
+Token::draw(GlRenderer& gl, const Camera& camera, const Light& light) const
+{
+    if (!loaded || packed_indices.empty()) return;
+
+    Mat4 proj  = gl.get_Projection();
+    Mat4 view  = buildViewMat4    (camera);
+    Mat4 model = buildModelMat4   (pos, yaw, pitch);
+    Mat4 mvp   = Mat4::mul        (proj, Mat4::mul(view, model));
+
+    gl.set_Wireframe(false);
+    gl.set_Light    (light);
+    gl.set_MVP      (model, mvp);
+    gl.set_NormalMat(model);
+    gl.set_FaceShade(color.r, color.g, color.b, back_dim, front_tint);
+
+    if (glow_enabled && glow_strength > 0.0f)
+        gl.set_Emissive(color.r, color.g, color.b, glow_strength);
+    else
+        gl.set_Emissive(0.0f, 0.0f, 0.0f, 0.0f);
+
+    // Draw the actual word FIRST, while MVP/normal/face-shade are still correct.
+    gl.draw_MeshCached(gpu_vbo, gpu_ebo, gpu_index_count, gpu_dirty,
+                       packed_verts.data(), (int)packed_verts.size() / 9,
+                       packed_indices.data(), (int)packed_indices.size());
+    gpu_dirty = false;
+
+    gl.set_Emissive (0.0f, 0.0f, 0.0f, 0.0f);
+    gl.set_FaceShade(0.0f, 0.0f, 0.0f, 1.0f, false);
+
+    // ── AI sparkle tail (only on active AI state) ─────────────────────────
+    if (ai.state == Ai::State::Idle) return;
+
+    const Uint64 now = SDL_GetTicks();
+
+    // Sample the word's path into a history ring → the trailing tail.
+    if (sparkle_trail_count == 0) {
+        for (int i = 0; i < SPARKLE_TRAIL; ++i) sparkle_trail[i] = pos;
+        sparkle_trail_count = SPARKLE_TRAIL;
+        sparkle_last_ms     = now;
+    }
+    else if (now - sparkle_last_ms >= 33) {              // ~30 Hz
+        for (int i = SPARKLE_TRAIL - 1; i > 0; --i)
+            sparkle_trail[i] = sparkle_trail[i - 1];
+        sparkle_trail[0] = pos;
+        sparkle_last_ms  = now;
+    }
+
+    const double t     = static_cast<double>(now) * 0.001;
+    const Vec3   fwd    = v_norm(camera.fwd);
+    const Vec3   right  = v_norm(camera.right);
+    const Vec3   up     = v_norm(Vec3{ right.y*fwd.z - right.z*fwd.y,
+                                       right.z*fwd.x - right.x*fwd.z,
+                                       right.x*fwd.y - right.y*fwd.x });
+    const Vec3   camN   = { -fwd.x, -fwd.y, -fwd.z };
+    const double sz     = SDL_max(half_extent, 0.25);
+    const uint32_t wseed = static_cast<uint32_t>(body.hover_phase * 10037.0) | 1u;
+
+    static const SDL_FColor PAL[] = {
+        {1.00f, 0.72f, 0.10f, 1.0f},  // gold
+        {1.00f, 0.25f, 0.65f, 1.0f},  // magenta-pink
+        {0.20f, 0.80f, 1.00f, 1.0f},  // cyan
+        {0.45f, 1.00f, 0.30f, 1.0f},  // lime
+        {1.00f, 0.45f, 0.10f, 1.0f},  // orange
+        {0.65f, 0.40f, 1.00f, 1.0f},  // violet
+    };
+    constexpr int PALN = sizeof(PAL) / sizeof(PAL[0]);
+
+    DynArray<float> sv[PALN];
+    DynArray<int>   si[PALN];
+
+    constexpr int PER_NODE = 2;
+    for (int j = 0; j < sparkle_trail_count; ++j) {
+        const double age  = (sparkle_trail_count > 1)
+                          ? double(j) / double(sparkle_trail_count - 1) : 0.0;
+        const Vec3&  node = sparkle_trail[j];
+
+        for (int s = 0; s < PER_NODE; ++s) {
+            uint32_t h = wseed ^ (uint32_t(j) * 2654435761u) ^ (uint32_t(s) * 40503u);
+            if (hash01(h) < 0.6) continue;               // sparser → calmer
+
+            double phase = hash01(h ^ 0xA5A5u) * 6.28318;
+            double tw    = SDL_pow(SDL_max(0.0, SDL_sin(t * 1.6 + phase)), 3.0);  // slower, softer
+            if (tw < 0.04) continue;
+
+            double spread = sz * (0.6 + age * 1.4);        // older = wider scatter
+            double jx = (hash01(h ^ 0x1111u) - 0.5) * spread;
+            double jy = (hash01(h ^ 0x2222u) - 0.5) * spread;
+
+            // gentle independent wander so each sparkle drifts in place
+            double wamp = sz * 0.22;                        // how far it roams
+            double fx   = 0.6 + hash01(h ^ 0x7777u) * 0.8;  // per-sparkle drift rates
+            double fy   = 0.6 + hash01(h ^ 0x8888u) * 0.8;
+            jx += SDL_sin(t * fx + hash01(h ^ 0x5555u) * 6.28318) * wamp;
+            jy += SDL_cos(t * fy + hash01(h ^ 0x6666u) * 6.28318) * wamp;
+
+            Vec3 ctr = { node.x + right.x*jx + up.x*jy + camN.x*0.03,
+                         node.y + right.y*jx + up.y*jy + camN.y*0.03,
+                         node.z + right.z*jx + up.z*jy + camN.z*0.03 };
+
+            double size_rng = 0.06 + 0.05 * hash01(h ^ 0x3333u);   // smaller + varied
+            double outer    = size_rng * sz * tw * (1.0 - age * 0.6);
+            if (outer < 1e-4) continue;
+
+            int c = int(hash01(h ^ 0x4444u) * PALN) % PALN;
+            append_disc(sv[c], si[c], ctr, right, up, camN, static_cast<float>(outer));
+        }
+    }
+
+    Mat4 ident = buildModelMat4(Vec3{0,0,0}, 0.0f, 0.0f);
+    gl.set_MVP      (ident, Mat4::mul(proj, view));
+    gl.set_NormalMat(ident);
+
+    const SDL_FColor glint = aiGlintColor(ai.state);
+    for (int c = 0; c < PALN; ++c) {
+        if (si[c].empty()) continue;
+        SDL_FColor g = mix_col(PAL[c], glint, 0.15f);     // keep only a hint of state
+        gl.set_FaceShade  (0.0f, 0.0f, 0.0f, 1.0f, false);  // no lit term → no white-out
+        gl.set_Emissive   (g.r, g.g, g.b, 0.85f);           // pure colour, below clip
+        gl.draw_MeshCached(gpu_vbo_sparkle, gpu_ebo_sparkle,
+                           gpu_sparkle_index_count, /*dirty=*/true,
+                           sv[c].data(), (int)sv[c].size() / 9,
+                           si[c].data(), (int)si[c].size());
+    }
+    gl.set_Emissive (0.0f, 0.0f, 0.0f, 0.0f);
+    gl.set_FaceShade(0.0f, 0.0f, 0.0f, 1.0f, false);
+}
+
+} // namespace Text
diff --git a/src/text/token.hpp b/src/text/token.hpp
new file mode 100644
index 0000000..816b9aa
--- /dev/null
+++ b/src/text/token.hpp
@@ -0,0 +1,179 @@
+#pragma once
+//
+// text/token.hpp  —  Phase 1 contract
+//
+// Text::Token is the renamed, cleaned-up successor to Text3d. It is still the
+// atomic unit: ONE whitespace-delimited word == ONE extruded mesh (TextMesh
+// rasterizes the whole string in one pass, so this granularity is fixed by the
+// mesh layer). It remains a GameObject so physics / pick / projectile code keep
+// working unchanged.
+//
+// Three things change vs. Text3d:
+//   1. Color provenance is explicit (ColorSource) instead of the {0,-1,0}
+//      sentinel decoded inside set_Look.
+//   2. byte_begin/byte_end are first-class (were only in Text::View) so a
+//      Handler can map tree-sitter spans straight onto the token.
+//   3. The AI state is an OPTIONAL owned component. Free/floor words own one;
+//      document tokens (editor/program/info) carry nullptr and stay lean.
+//
+
+#include <SDL3/SDL.h>
+#include <cstdint>
+#include <string>
+
+#include "entities/game_object.hpp"     // GameObject, RigidBody, CollisionShape, ObjectType, PhysicsMode
+#include "text_3d/text_mesh.hpp"        // TextMesh
+#include "utils/config.hpp"             // ConfigColor (defaults)
+#include "utils/globals.hpp"            // Vec3, DynArray, cpputils::unique_ptr
+#include "utils/light.hpp"
+
+class Camera;
+class GlRenderer;
+
+namespace Text
+{
+
+// Where a token's color comes from. Replaces the magic-float ConfigColor::keep.
+//   Lexed  — colored by the owning Handler's tree-sitter spans (apply_Highlight).
+//   Lint   — self-classified as a lone token: classify_Word(text). Free words on
+//            the floor, and AI words returning to Idle, use this.
+//   Static — a fixed color set explicitly and never recomputed: line numbers,
+//            program output (dark_gray), AI state tints (attack/flee/stunned),
+//            info/compiler text.
+enum class ColorSource : uint8_t { Lexed, Lint, Static };
+
+// Rotate-to-eye policy. Stored on the Handler (document-wide) and applied to its
+// tokens each frame; kept here so Token and Handler share the vocabulary.
+//   None  — fixed yaw/pitch (free floor words).
+//   Yaw   — follow the eye horizontally (carried words).
+//   Pitch — tilt up/down toward the eye, clamped (wall text: tilt_TowardEye).
+//   Both  — billboard fully (labels).
+enum class FaceMode : uint8_t { None, Yaw, Pitch, Both };
+
+
+struct Token final : public GameObject
+{
+    // ── shared constants (unchanged from Text3d) ──────────────────────────
+    static inline const std::string font_path =
+        "/usr/share/fonts/dejavu/DejaVuSansMono.ttf";
+
+    static inline constexpr float depth_wall     = 0.06f;
+    static inline constexpr float depth_floor    = 0.14f;
+    static inline constexpr float depth_compiler = 0.02f;
+
+
+    // ── AI component (was Text3dAI; now owned only by free words) ──────────
+    struct Ai
+    {
+        enum class State : uint8_t { Idle, Attack, Flee, Stunned };
+
+        State  state              = State::Idle;
+        double state_timer        = 0.0;
+        double aggressiveness     = 0.0;
+        double phase_timer        = 0.0;
+        double wiggle_phase       = 0.0;
+        double wiggle_freq        = 4.0;
+        double speed              = 2.0;
+
+        bool   in_pause           = false;
+        double hover_height_saved = 0.0;
+        double orbit_angle        = 0.0;
+        double orbit_dir          = 1.0;
+        bool   has_stolen         = false;
+
+        DynArray<cpputils::unique_ptr<Token>> stolen;   // carried-off loot
+        Vec3 trail_dir { 1.0, 0.0, 0.0 };
+    };
+
+
+    // ── text / appearance ─────────────────────────────────────────────────
+    std::string text;
+    SDL_FColor  color;
+    TextMesh    mesh;
+
+    ColorSource color_src { ColorSource::Lint };
+
+    float depth;
+    float scale;
+    float half_extent   { 0.0f };   // width  half-extent (along the glyph row)
+    float half_extent_y { 0.2f };   // height half-extent
+    float pitch         { 0.0f };
+
+    bool  loaded        { false };
+    bool  front_tint    { true };
+    bool  glow_enabled  { false };
+    float glow_strength { 0.2f };
+    float back_dim      { 0.45f };
+    Vec3  ribbon_local  {};
+
+
+    // ── byte span (valid only while owned by a Handler; set by render_Source)
+    size_t byte_begin { 0 };
+    size_t byte_end   { 0 };
+
+
+    // ── AI state (by-value; idle on document tokens, animated only on free
+    //    words by WordAiSystem — matches the old Text3d::ai member exactly) ──
+    Ai ai;
+
+
+    // ── GPU caches (mutable; identical to Text3d) ─────────────────────────
+    mutable bool         gpu_dirty       { true };
+    mutable int          gpu_index_count { 0 };
+    mutable unsigned int gpu_vbo         { 0 };
+    mutable unsigned int gpu_ebo         { 0 };
+    mutable unsigned int gpu_vbo_sparkle { 0 };
+    mutable unsigned int gpu_ebo_sparkle { 0 };
+    mutable int          gpu_sparkle_index_count { 0 };
+
+    static constexpr int SPARKLE_TRAIL = 14;
+    mutable int    sparkle_trail_count { 0 };
+    mutable Uint64 sparkle_last_ms     { 0 };
+    mutable Vec3   sparkle_trail[SPARKLE_TRAIL];
+
+    DynArray<int>   packed_indices;
+    DynArray<float> packed_verts;
+
+
+    // ── lifetime ───────────────────────────────────────────────────────────
+    Token(const std::string& txt,
+          float depth, float scale,
+          Vec3  position,
+          SDL_FColor colr = ConfigColor::blue);
+    ~Token() override;
+
+
+    // ── colour ────────────────────────────────────────────────────────────
+    // set_Color    — set the colour directly (does NOT change color_src; matches
+    //                old Text3d::set_Color, so existing call sites keep working).
+    // set_Look     — legacy shim: ConfigColor::keep keeps colour, an r==-1
+    //                sentinel reclassifies, otherwise sets colour; then depth.
+    // recolor      — Lint: reclassify from text. Lexed/Static: no-op.
+    // color_src is only consulted by Handler::apply_Highlight (which recolours
+    // its Lexed tokens) and recolor(); free/floor tokens ignore it.
+    void set_Color      (SDL_FColor c);
+    void set_Look       (SDL_FColor c, float new_depth);
+    void set_ColorSource(ColorSource src) { color_src = src; }
+    void recolor        ();
+
+    void set_Depth   (float depth);   // rebuilds mesh only if depth changed; never touches colour
+    void rebuild_Mesh();
+
+
+    // ── misc (unchanged) ───────────────────────────────────────────────────
+    static Vec3 words_centroid(const DynArray<cpputils::unique_ptr<Token>>& words);
+    Vec3 get_Distance(Vec3 position) const;
+    void update      (const Vec3& camera_pos, const double view_mat[9], float carry_dist = 1.5f);
+    void draw        (GlRenderer& gl, const Camera& camera, const Light& light) const override;
+};
+
+
+using TokenPtr = cpputils::unique_ptr<Token>;
+
+template<typename... Args>
+TokenPtr make_Token(Args&&... args)
+{
+    return cpputils::make_unique<Token>(std::forward<Args>(args)...);
+}
+
+} // namespace Text
diff --git a/src/text/vim_buffer.cpp b/src/text/vim_buffer.cpp
new file mode 100644
index 0000000..dbef74e
--- /dev/null
+++ b/src/text/vim_buffer.cpp
@@ -0,0 +1,265 @@
+#include "vim_buffer.hpp"
+
+
+void
+VimBuffer::load(const std::vector<std::string>& src)
+{
+    _lines = src;
+    if (_lines.empty()) _lines.push_back("");
+    _row   = _col = 0; _mode = Mode::Normal;
+    _count = 0;
+    _sig   = Signal::None; ++_version;
+    _saved = _lines;
+    _dirty = false;
+
+    _pending.clear();
+    _cmd    .clear();
+    _status .clear();
+}
+
+
+void
+VimBuffer::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
+VimBuffer::key_enter()
+{
+    if      (_mode == Mode::Insert)  { split_Line(); }
+    else if (_mode == Mode::Command) { run_Command(); }
+    else                             { _row = get_ClampRow(_row + 1); clamp_Col(); touch(); }
+}
+
+void VimBuffer::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 VimBuffer::key_escape()
+{
+    if (_mode == Mode::Command) { cancel_Command(); return; }
+    _mode = Mode::Normal;
+    if (_col > 0) --_col;
+    clamp_Col(); touch();
+}
+
+
+VimBuffer::Signal
+VimBuffer::take_Signal()
+{ Signal s = _sig; _sig = Signal::None; return s; }
+
+
+void
+VimBuffer::mark_Saved()
+{ _saved = _lines; _dirty = false; touch(); }
+
+
+// Shoot-merge entry point. Splices a whole word into a line so it tokenizes
+// back out as its own token; the caller (Text::Handler::attach) has already
+// snapped `col` to an inter-token gap. Supports row == size() (append a row).
+void
+VimBuffer::insert_Word_At(int row, int col, const std::string& word)
+{
+    if (word.empty()) return;
+    if (_lines.empty()) _lines.push_back("");
+
+    if (row < 0) row = 0;
+    if (row >= (int)_lines.size()) {
+        _lines.resize((size_t)row + 1);   // pad with blank rows (at most one, by caller)
+        col = 0;
+    }
+
+    std::string& L = _lines[(size_t)row];
+    if (col < 0)            col = 0;
+    if (col > (int)L.size()) col = (int)L.size();
+
+    // Add separating spaces only where the splice would otherwise fuse with
+    // neighbouring text, so the new word stays a standalone token.
+    std::string ins;
+    if (col > 0 && L[(size_t)(col - 1)] != ' ') ins += ' ';
+    ins += word;
+    if (col < (int)L.size() && L[(size_t)col] != ' ') ins += ' ';
+
+    L.insert((size_t)col, ins);
+
+    // Keep the cursor on the same character if the splice landed at/left of it.
+    if (row == _row && col <= _col) _col += (int)ins.size();
+
+    _dirty = true;
+    clamp_Col();
+    touch();
+}
+
+
+void
+VimBuffer::touch()
+{ ++_version; }
+
+int
+VimBuffer::get_LineLen() const
+{ return (int)_lines[(size_t)_row].size(); }
+
+int
+VimBuffer::get_ClampRow(int r) const
+{ int n=(int)_lines.size(); return r<0?0:(r>=n?n-1:r); }
+
+void
+VimBuffer::clamp_Col()
+{
+    int maxc = (_mode==Mode::Insert) ? get_LineLen() : (get_LineLen()>0 ? get_LineLen()-1 : 0);
+    if (_col < 0) _col = 0;
+    if (_col > maxc) _col = maxc;
+}
+
+int
+VimBuffer::get_Eff() const
+{ return _count > 0 ? _count : 1; }
+
+void
+VimBuffer::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; }
+        _count = _count * 10 + (c - '0'); return;
+    }
+
+    if (_pending == "d") { _pending.clear();
+        if (c == 'd') { delete_Lines(get_Eff()); _count = 0;
+                        touch(); return; }
+        _count = 0; return;
+    }
+    if (_pending == "g") { _pending.clear();
+        if (c == 'g') { _row = get_ClampRow(_count > 0 ? _count - 1 : 0);
+                        _count = 0; clamp_Col(); touch(); return; }
+        _count = 0; return;
+    }
+
+    switch (c) {
+        case 'h': _col -= get_Eff();                            break;
+        case 'l': _col += get_Eff();                            break;
+        case 'j': _row = get_ClampRow(_row + get_Eff());        break;
+        case 'k': _row = get_ClampRow(_row - get_Eff());        break;
+        case '0': _col = 0;                                     break;
+        case '$': _col = get_LineLen() > 0 ? get_LineLen() - 1 : 0;                    break;
+        case 'x': delete_Chars(get_Eff());                      break;
+        case 'i': _mode = Mode::Insert;                         break;
+        case 'a': _col = (get_LineLen() > 0 ? _col + 1 : 0); _mode = Mode::Insert;     break;
+        case 'A': _col = get_LineLen(); _mode = Mode::Insert;   break;
+        case 'o': open_Below(); _mode = Mode::Insert;           break;
+        case 'O': open_Above(); _mode = Mode::Insert;           break;
+        case 'G': _row = get_ClampRow(_count > 0 ? _count - 1 : (int)_lines.size() - 1);break;
+        case 'd': _pending = "d";                               return;
+        case 'g': _pending = "g";                               return;
+        default: break;
+    }
+    _count = 0; clamp_Col(); touch();
+}
+
+void
+VimBuffer::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; _dirty = true; touch();
+}
+
+void
+VimBuffer::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; _dirty = true; touch();
+}
+
+void
+VimBuffer::backspace_Insert()
+{
+    if (_col > 0) { _lines[(size_t)_row].erase((size_t)(_col-1), 1); --_col; _dirty = true; }
+    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; _dirty = true;
+    }
+    touch();
+}
+
+void
+VimBuffer::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); _dirty = true; }
+    clamp_Col();
+}
+
+void
+VimBuffer::delete_Lines(int n)
+{
+    for (int i = 0; i < n && (int)_lines.size() > 0; ++i) {
+        if (_lines.size() == 1) { _lines[0].clear(); _dirty = true; break; }
+        _lines.erase(_lines.begin() + _row);
+        if (_row >= (int)_lines.size()) _row = (int)_lines.size() - 1;
+        _dirty = true;
+    }
+    clamp_Col();
+}
+void
+VimBuffer::open_Below()
+{ _lines.insert(_lines.begin() + _row + 1, ""); ++_row; _col = 0; _dirty = true; }
+
+void
+VimBuffer::open_Above()
+{ _lines.insert(_lines.begin() + _row,     ""); _col = 0; _dirty = true; }
+
+void
+VimBuffer::run_Command()
+{
+    std::string c = _cmd.size() ? _cmd.substr(1) : "";
+    _cmd.clear(); _mode = Mode::Normal;
+
+    if (c == "w" || c == "write") {
+        _sig = Signal::Write;
+    }
+    else if (c == "wq" || c == "x") {
+        _sig = Signal::WriteQuit;
+    }
+    else if (c == "q" || c == "quit") {
+        if (_dirty) {
+            _status = "No write since last change (add ! to override)";
+        } else {
+            _sig = Signal::Exit;
+        }
+    }
+    else if (c == "q!" || c == "quit!") {
+        _lines = _saved;
+        _row = get_ClampRow(_row); clamp_Col();
+        _dirty = false;
+        _sig = Signal::QuitDiscard;
+    }
+    else if (!c.empty() && std::all_of(c.begin(), c.end(),
+             [](char ch){ return std::isdigit((unsigned char)ch); })) {
+        _row = get_ClampRow(std::stoi(c) - 1); clamp_Col();
+    }
+    else _status = "not a command: " + c;
+    touch();
+}
+
+void
+VimBuffer::cancel_Command()
+{ _cmd.clear(); _mode = Mode::Normal; touch(); }
+
+bool
+VimBuffer::is_Dirty() const
+{ return _dirty; }
diff --git a/src/text/vim_buffer.hpp b/src/text/vim_buffer.hpp
new file mode 100644
index 0000000..f8919b0
--- /dev/null
+++ b/src/text/vim_buffer.hpp
@@ -0,0 +1,82 @@
+#pragma once
+
+#include <algorithm>
+#include <cstdint>
+#include <string>
+#include <vector>
+
+class VimBuffer {
+public:
+    enum class Mode   : uint8_t { Normal, Insert, Command };
+    enum class Signal : uint8_t {
+        None,
+        Exit,
+        Help,
+        Write,
+        WriteQuit,
+        QuitDiscard
+    };
+
+    void key_char     (char c);
+    void key_enter    ();
+    void key_backspace();
+    void key_escape   ();
+
+    void   load       (const std::vector<std::string>& src);
+    Signal take_Signal();
+    void   mark_Saved ();
+
+    // Shoot-merge: splice a whole word into line `row` at character `col`,
+    // adding surrounding spaces so it tokenizes back out as its own token.
+    // `col` is expected pre-snapped to an inter-token gap by the caller
+    // (Text::Handler::attach). Clamps row/col, bumps version, marks dirty.
+    void   insert_Word_At(int row, int col, const std::string& word);
+
+    bool is_Empty() const { return _lines.size() == 1 && _lines[0].empty(); }
+
+    const std::vector<std::string>&
+         get_Lines   () const { return _lines; }
+    int  get_Row     () const { return _row; }
+    int  get_Col     () const { return _col; }
+    Mode get_Mode    () const { return _mode; }
+    unsigned
+         get_Version () const { return _version; }
+    const std::string&
+         get_Cmdline () const { return _cmd; }
+    const std::string&
+         get_Status  () const { return _status; }
+    void start_Insert() { _mode = Mode::Insert; _col = 0; touch(); }
+
+private:
+    bool     _dirty = false;
+    unsigned _version = 0;
+    int      _row = 0, _col = 0;
+    int      _count = 0;
+
+    std::string _pending;
+    std::string _cmd;
+    std::string _status;
+
+    Mode     _mode = Mode::Normal;
+    Signal   _sig = Signal::None;
+    std::vector<std::string> _saved;
+    std::vector<std::string> _lines{ "" };
+
+    void touch           ();
+    void clamp_Col       ();
+    void normal_Key      (char c);
+    void insert_Char     (char c);
+    void split_Line      ();
+    void backspace_Insert();
+    void delete_Chars    (int n);
+    void delete_Lines    (int n);
+    void open_Below      ();
+    void open_Above      ();
+    void run_Command     ();
+    void cancel_Command  ();
+
+    bool is_Dirty        ()      const;
+    int  get_LineLen     ()      const;
+    int  get_Eff         ()      const;
+    int  get_ClampRow    (int r) const;
+};
diff --git a/src/text_3d/vim_buffer.cpp b/src/text_3d/vim_buffer.cpp
index 3c0fa79..b67aa1c 100644
--- a/src/text_3d/vim_buffer.cpp
+++ b/src/text_3d/vim_buffer.cpp
@@ -2,7 +2,7 @@
 
 
 void
-VimBuffer::load(const std::vector<std::string>& src)
+VimBufferOLD::load(const std::vector<std::string>& src)
 {
     _lines = src;
     if (_lines.empty()) _lines.push_back("");
@@ -19,7 +19,7 @@ VimBuffer::load(const std::vector<std::string>& src)
 
 
 void
-VimBuffer::key_char(char c)
+VimBufferOLD::key_char(char c)
 {
     if      (_mode == Mode::Insert)  { insert_Char(c); }
     else if (_mode == Mode::Command) { _cmd.push_back(c); touch(); }
@@ -27,21 +27,21 @@ VimBuffer::key_char(char c)
 }
 
 void
-VimBuffer::key_enter()
+VimBufferOLD::key_enter()
 {
     if      (_mode == Mode::Insert)  { split_Line(); }
     else if (_mode == Mode::Command) { run_Command(); }
     else                             { _row = get_ClampRow(_row + 1); clamp_Col(); touch(); }
 }
 
-void VimBuffer::key_backspace()
+void VimBufferOLD::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 VimBuffer::key_escape()
+void VimBufferOLD::key_escape()
 {
     if (_mode == Mode::Command) { cancel_Command(); return; }
     _mode = Mode::Normal;
@@ -50,30 +50,30 @@ void VimBuffer::key_escape()
 }
 
 
-VimBuffer::Signal
-VimBuffer::take_Signal()
+VimBufferOLD::Signal
+VimBufferOLD::take_Signal()
 { Signal s = _sig; _sig = Signal::None; return s; }
 
 
 void
-VimBuffer::mark_Saved()
+VimBufferOLD::mark_Saved()
 { _saved = _lines; _dirty = false; touch(); }
 
 
 void
-VimBuffer::touch()
+VimBufferOLD::touch()
 { ++_version; }
 
 int 
-VimBuffer::get_LineLen() const
+VimBufferOLD::get_LineLen() const
 { return (int)_lines[(size_t)_row].size(); }
 
 int
-VimBuffer::get_ClampRow(int r) const
+VimBufferOLD::get_ClampRow(int r) const
 { int n=(int)_lines.size(); return r<0?0:(r>=n?n-1:r); }
 
 void
-VimBuffer::clamp_Col()
+VimBufferOLD::clamp_Col()
 {
     int maxc = (_mode==Mode::Insert) ? get_LineLen() : (get_LineLen()>0 ? get_LineLen()-1 : 0);
     if (_col < 0) _col = 0;
@@ -81,11 +81,11 @@ VimBuffer::clamp_Col()
 }
 
 int
-VimBuffer::get_Eff() const
+VimBufferOLD::get_Eff() const
 { return _count > 0 ? _count : 1; }
 
 void
-VimBuffer::normal_Key(char c)
+VimBufferOLD::normal_Key(char c)
 {
     if (c == ':') { _mode = Mode::Command; _cmd = ":"; 
                     _status.clear(); touch(); return; }
@@ -129,7 +129,7 @@ VimBuffer::normal_Key(char c)
 }
 
 void
-VimBuffer::insert_Char(char c)
+VimBufferOLD::insert_Char(char c)
 {
     std::string& L = _lines[(size_t)_row];
     if (_col > (int)L.size()) _col = (int)L.size();
@@ -137,7 +137,7 @@ VimBuffer::insert_Char(char c)
 }
 
 void
-VimBuffer::split_Line()
+VimBufferOLD::split_Line()
 {
     std::string& L = _lines[(size_t)_row];
     std::string tail = L.substr((size_t)_col);
@@ -147,7 +147,7 @@ VimBuffer::split_Line()
 }
 
 void
-VimBuffer::backspace_Insert()
+VimBufferOLD::backspace_Insert()
 {
     if (_col > 0) { _lines[(size_t)_row].erase((size_t)(_col-1), 1); --_col; _dirty = true; }
     else if (_row > 0) {
@@ -160,7 +160,7 @@ VimBuffer::backspace_Insert()
 }
 
 void
-VimBuffer::delete_Chars(int n)
+VimBufferOLD::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); _dirty = true; }
@@ -168,7 +168,7 @@ VimBuffer::delete_Chars(int n)
 }
 
 void
-VimBuffer::delete_Lines(int n)
+VimBufferOLD::delete_Lines(int n)
 {
     for (int i = 0; i < n && (int)_lines.size() > 0; ++i) {
         if (_lines.size() == 1) { _lines[0].clear(); _dirty = true; break; }
@@ -179,15 +179,15 @@ VimBuffer::delete_Lines(int n)
     clamp_Col();
 }
 void
-VimBuffer::open_Below()
+VimBufferOLD::open_Below()
 { _lines.insert(_lines.begin() + _row + 1, ""); ++_row; _col = 0; _dirty = true; }
 
 void
-VimBuffer::open_Above()
+VimBufferOLD::open_Above()
 { _lines.insert(_lines.begin() + _row,     ""); _col = 0; _dirty = true; }
 
 void
-VimBuffer::run_Command()
+VimBufferOLD::run_Command()
 {
     std::string c = _cmd.size() ? _cmd.substr(1) : "";
     _cmd.clear(); _mode = Mode::Normal;
@@ -220,9 +220,9 @@ VimBuffer::run_Command()
 }
 
 void
-VimBuffer::cancel_Command()
+VimBufferOLD::cancel_Command()
 { _cmd.clear(); _mode = Mode::Normal; touch(); }
 
 bool
-VimBuffer::is_Dirty() const
+VimBufferOLD::is_Dirty() const
 { return _dirty; }
diff --git a/src/text_3d/vim_buffer.hpp b/src/text_3d/vim_buffer.hpp
index c70fdb3..d470e73 100644
--- a/src/text_3d/vim_buffer.hpp
+++ b/src/text_3d/vim_buffer.hpp
@@ -5,7 +5,7 @@
 #include <string>
 #include <vector>
 
-class VimBuffer {
+class VimBufferOLD {
 public:
     enum class Mode   : uint8_t { Normal, Insert, Command };
     enum class Signal : uint8_t {
diff --git a/src/utils/carry_math.cpp b/src/utils/carry_math.cpp
deleted file mode 100644
index a247648..0000000
--- a/src/utils/carry_math.cpp
+++ /dev/null
@@ -1,64 +0,0 @@
-#include <SDL3/SDL.h>
-
-#include "carry_math.hpp"
-#include "entities/camera.hpp"
-
-
-Vec3
-CarryMath::get_LineAnchor(const Camera& camera, double carry_dist)
-{
-    const Vec3& right = camera.right;
-    const Vec3  up    = Math::cross(camera.right, camera.fwd);
-
-    return {
-        camera.pos.x + camera.fwd.x * carry_dist
-                     + right.x * CARRY_HAND_RIGHT - up.x * CARRY_HAND_DOWN,
-        camera.pos.y + camera.fwd.y * carry_dist
-                     + right.y * CARRY_HAND_RIGHT - up.y * CARRY_HAND_DOWN,
-        camera.pos.z + camera.fwd.z * carry_dist
-                     + right.z * CARRY_HAND_RIGHT - up.z * CARRY_HAND_DOWN
-    };
-}
-
-
-DynArray<Vec3>
-CarryMath::get_SlotPosition(const CarriedLine& carried,
-                            const Vec3& anchor,
-                            const Vec3& right,
-                            double carry_gap)
-{
-    DynArray<Vec3> positions;
-    DynArray<CarriedWordNode*> nodes = carried.reverse_nodes();
-
-    if (nodes.empty()) return positions;
-
-    double current_offset = 0.0;
-
-    for (size_t i = 0; i < nodes.size(); i++) {
-        if (!nodes[i]->word) continue;
-
-        positions.push_back({
-            anchor.x + right.x * current_offset,
-            anchor.y + right.y * current_offset,
-            anchor.z + right.z * current_offset
-        });
-
-        if (i + 1 < nodes.size() && nodes[i + 1]->word) {
-            double a = 0.5, b = 0.5;
-
-            if (nodes[i]->word->type == ObjectType::Text)
-                a = static_cast<const Text3d&>(*nodes[i]->word).half_extent;
-            else
-                a = nodes[i]->word->shape.half_extents.x;
-
-            if (nodes[i+1]->word->type == ObjectType::Text)
-                b = static_cast<const Text3d&>(*nodes[i+1]->word).half_extent;
-            else
-                b = nodes[i+1]->word->shape.half_extents.x;
-
-            current_offset -= (a + b + carry_gap);
-        }
-    }
-
-    return positions;
-}
diff --git a/src/utils/carry_math.hpp b/src/utils/carry_math.hpp
deleted file mode 100644
index c11f35e..0000000
--- a/src/utils/carry_math.hpp
+++ /dev/null
@@ -1,25 +0,0 @@
-#pragma once
-
-#include "systems/carry_system.hpp"
-#include "utils/globals.hpp"
-
-
-class Camera;
-
-
-namespace CarryMath
-{
-    static constexpr double CARRY_DIST = 1.2;
-    static constexpr double CARRY_GAP  = 0.18;
-
-    static constexpr double CARRY_HAND_RIGHT = 0.45;
-    static constexpr double CARRY_HAND_DOWN  = 0.35;
-
-
-    Vec3 get_LineAnchor   (const Camera& camera, double carry_dist);
-    DynArray<Vec3>
-         get_SlotPosition(const CarriedLine& carried,
-                          const Vec3& anchor,
-                          const Vec3& right,
-                          double carry_gap);
-}
diff --git a/src/utils/config.hpp b/src/utils/config.hpp
index 448f276..b06462e 100644
--- a/src/utils/config.hpp
+++ b/src/utils/config.hpp
@@ -28,6 +28,15 @@ static constexpr double proj_t_max       = 1.2;   // clamp: farthest throws
 }
 
 
+namespace ConfigCarry
+{
+static constexpr double dist = 1.2;
+static constexpr double gap  = 0.18;
+
+static constexpr double hand_right = 0.45;
+static constexpr double hand_down  = 0.35;
+}
+
 namespace ConfigColor
 {
     static inline constexpr SDL_FColor keep    = {0.0f, -1.0f, 0.0f,  1.0f};
diff --git a/src/wall/editor.hpp b/src/wall/editor.hpp
index c1cde5f..915fb42 100644
--- a/src/wall/editor.hpp
+++ b/src/wall/editor.hpp
@@ -75,10 +75,10 @@ public:
     double scroll_DeltaToEye(size_t line_id, double eye_y) const;
     size_t line_Count       () const { return _lines.size(); }
     DynArray<DynArray<Text3d*>>
-           lines_InOrder() const;
+           lines_InOrder    () const;
 
     DynArray<NumberHit>
-           number_Hitboxes() const;
+           number_Hitboxes  () const;
 };
 
 } // namespace Wall
diff --git a/src/wall/text.cpp b/src/wall/text.cpp
index 676edcd..920c0f3 100644
--- a/src/wall/text.cpp
+++ b/src/wall/text.cpp
@@ -40,7 +40,7 @@ Text::from_Drop(Text3dPtr src)
 
     Wall::Role role = Wall::role_Of(wall_pos);
 
-    return cpputils::make_unique<Wall::Text>(Wall::Text{
+    return cpputils::make_unique<Text>(Text{
         wall_pos,
         role,
         std::move(src)
@@ -51,7 +51,7 @@ Text::from_Drop(Text3dPtr src)
 std::string
 Text::extract_Text(const PtrList& texts, Wall::Role role, double line_threshold)
 {
-    DynArray<Wall::Text*> items;
+    DynArray<Text*> items;
     items.reserve(texts.size());
 
     for (const Ptr& wt : texts) {
@@ -65,19 +65,19 @@ Text::extract_Text(const PtrList& texts, Wall::Role role, double line_threshold)
     const Wall::Position wall_pos = Wall::position_Of(role);
 
     std::sort(items.begin(), items.end(),
-        [](const Wall::Text* a, const Wall::Text* b) {
+        [](const Text* a, const Text* b) {
             return a->text3d->pos.y > b->text3d->pos.y;
         });
 
     struct Line {
-        DynArray<Wall::Text*> items;
+        DynArray<Text*> items;
         double sum_y = 0.0;
     };
 
     DynArray<Line> lines;
     lines.reserve(items.size());
 
-    for (Wall::Text* wt : items) {
+    for (Text* wt : items) {
         const double y = wt->text3d->pos.y;
 
         if (!lines.empty()) {
@@ -96,7 +96,7 @@ Text::extract_Text(const PtrList& texts, Wall::Role role, double line_threshold)
 
     for (Line& line : lines) {
         std::sort(line.items.begin(), line.items.end(),
-            [&](const Wall::Text* a, const Wall::Text* b) {
+            [&](const Text* a, const Text* b) {
                 if (wall_pos == Wall::Position::PosX ||
                     wall_pos == Wall::Position::NegX)
                     return a->text3d->pos.z > b->text3d->pos.z;
@@ -110,7 +110,7 @@ Text::extract_Text(const PtrList& texts, Wall::Role role, double line_threshold)
         for (size_t i = 0; i < line.items.size(); ++i) {
             if (i) result += ' ';
 
-            const Wall::Text* wt = line.items[i];
+            const Text* wt = line.items[i];
             if (wt->text3d->type == ObjectType::Text)
                 result += wt->text3d->text;
         }
@@ -151,7 +151,7 @@ Text::show_TokenInfo(PtrList& texts, const std::string& token_text)
         Vec3 pos = tf.position; pos.y = y;
         Text3dPtr t = make_Text3d(line, Text3d::depth_wall, 0.003f, pos, ConfigColor::white);
         t->yaw = tf.yaw;
-        texts.push_back(cpputils::make_unique<Wall::Text>(Wall::Text{
+        texts.push_back(cpputils::make_unique<Text>(Text{
             tf.wall_position, Wall::Role::Info, std::move(t) }));
         y -= ConfigWall::text_line_spacing;
     }