Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / 4305c42

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

Restructure Text With Claude

Christian Immanuel · 2026-06-09 08:31 · 4305c429ee06e9b6d0d7d0f1bf96add490f39e8e

 src/entities/pult.hpp             |   4 +-
 src/levels/director.cpp           |  26 +-
 src/states/game_context.hpp       |   6 +
 src/states/game_state.cpp         | 533 ++++++++++++++++----------------------
 src/states/game_state.hpp         |  44 ++--
 src/systems/aim.hpp               |   8 +
 src/systems/compiler_system.cpp   |  36 +--
 src/systems/compiler_system.hpp   |   7 +-
 src/systems/dyn_loader_system.cpp |  51 +---
 src/systems/dyn_loader_system.hpp |  11 +-
 src/systems/hint_system.hpp       |   8 +-
 src/systems/physics_system.cpp    |   6 +-
 src/systems/physics_system.hpp    |   4 +-
 src/systems/pick_system.cpp       |  35 ++-
 src/systems/pick_system.hpp       |  15 +-
 src/systems/program_system.cpp    |  89 ++-----
 src/systems/program_system.hpp    |  12 +-
 src/systems/pult_system.cpp       |   6 +-
 src/systems/pult_system.hpp       |   5 +-
 src/systems/wall_connection.cpp   | 314 +++-------------------
 src/systems/wall_connection.hpp   |  59 ++---
 src/text/handler.cpp              | 260 +++++++++++++++++--
 src/text/handler.hpp              |  43 ++-
 src/text/text.cpp                 |   0
 src/text/text.hpp                 |   0
 src/text/token.hpp                |   5 +
 src/text_3d/text_3d.cpp           | 351 -------------------------
 src/text_3d/text_3d.hpp           | 123 ++-------
 src/text_3d/text_label.cpp        | 260 -------------------
 src/text_3d/text_label.hpp        |  71 -----
 src/text_3d/vim_buffer.cpp        | 228 ----------------
 src/text_3d/vim_buffer.hpp        |  76 ------
 src/utils/globals.hpp             |   8 +-
 src/utils/text.cpp                |  26 +-
 src/utils/text.hpp                |  18 +-
 src/wall/label.cpp                |   4 +-
 src/wall/label.hpp                |  29 +++
 src/wall/text.cpp                 |  87 +------
 src/wall/wall.hpp                 |  27 +-
 39 files changed, 805 insertions(+), 2090 deletions(-)

diff --git a/src/entities/pult.hpp b/src/entities/pult.hpp
index 82e4c68..53c93fc 100644
--- a/src/entities/pult.hpp
+++ b/src/entities/pult.hpp
@@ -3,9 +3,9 @@
 #include <string>
 
 #include "entities/solid_box.hpp"
-#include "text_3d/text_label.hpp"
 #include "utils/globals.hpp"
 #include "utils/light.hpp"
+#include "text/handler.hpp"
 
 class Camera;
 class GlRenderer;
@@ -42,7 +42,7 @@ struct PultButton final : public SolidBox
 struct Pult : public SolidBox
 {
     std::string name;
-    TextLabel   label;
+    Text::Handler   label;
     DynArray<PultButton*>
                 buttons;
 
diff --git a/src/levels/director.cpp b/src/levels/director.cpp
index 99cbf22..4b6b08d 100644
--- a/src/levels/director.cpp
+++ b/src/levels/director.cpp
@@ -145,31 +145,20 @@ Director::teardown_Scene(GameContext& ctx)
 void
 Director::show_Details(GameContext& ctx) const
 {
-    ctx.clear_WallRole(Wall::Role::Info);
+    if (!ctx.info_wall) return;
+    Text::Handler& info = *ctx.info_wall;
+    info.clear();
 
     if (_runner->is_Complete()) return;
 
     const std::string& details = _runner->get_Current().details;
     if (details.empty()) return;
 
-    Wall::Transform wt = Wall::get_Transform(Wall::Role::Info, World::get_BoxScale());
-
     constexpr size_t MAX_INFO_LINE_CHARS = 92;
     DynArray<std::string> lines = Text::wrap_String(details, MAX_INFO_LINE_CHARS);
 
-    const float bottom_y = World::get_FloorY() + 1.0f;
-    float y = bottom_y + static_cast<float>(lines.size() - 1) * ConfigWall::text_line_spacing;
-
-    for (const std::string& line : lines) {
-        Vec3 pos = wt.position;
-        pos.y = y;
-        y -= ConfigWall::text_line_spacing;
-        if (line.empty()) continue;
-
-        Text3dPtr t = make_Text3d(line, Text3d::depth_wall, 0.0025f, pos, ConfigColor::white);
-        t->yaw = wt.yaw;
-        ctx.add_WallText(wt.wall_position, Wall::Role::Info, std::move(t));
-    }
+    for (const std::string& line : lines)
+        if (!line.empty()) info.append_Line(line);
 }
 
 
@@ -189,8 +178,9 @@ Director::build_GoalCtx(const GameContext& ctx) const
     gctx.ai_words_defeated_this_step = _ledger.ai_defeated;
     gctx.picked_up_this_step         = _ledger.picked_up;
 
-    if (gctx.compile_ok_this_step) {
-        std::string src = Wall::Text::extract_Text(ctx.wall_texts, Wall::Role::Editor);
+    if (gctx.compile_ok_this_step && ctx.editor_wall) {
+        std::string src;
+        for (const std::string& l : ctx.editor_wall->as_Lines()) { src += l; src += '\n'; }
         gctx.editor_normalized = Level::Check::normalize(src);
     }
     return gctx;
diff --git a/src/states/game_context.hpp b/src/states/game_context.hpp
index 4962355..f0b0065 100644
--- a/src/states/game_context.hpp
+++ b/src/states/game_context.hpp
@@ -8,11 +8,13 @@
 #include "wall/wall.hpp"
 
 
+
 class  Camera;
 class  Player;
 class  World;
 struct Light;
 struct PultButton;
+namespace Text { class Handler; }
 
 
 struct GameContext
@@ -22,6 +24,10 @@ struct GameContext
     cpputils::unique_ptr<World>  world;
     cpputils::unique_ptr<Light>  light;
 
+
+    Text::Handler* editor_wall { nullptr };
+    Text::Handler* info_wall   { nullptr };
+
     DynArray<Text3dPtr>     words;
     DynArray<GameObjectPtr> scenery;
     DynArray<PultButton*>   buttons;
diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index b3002da..6db4cf1 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -15,30 +15,13 @@
 #include "systems/physics_system.hpp"
 #include "systems/pick_system.hpp"
 #include "systems/source_tokens.hpp"
-#include "text_3d/text_label.hpp"
 #include "utils/config.hpp"
 #include "utils/frame_profiler.hpp"
-#include "utils/text.hpp"
+#include "wall/label.hpp"
 #include "wall/wall.hpp"
 
 
 
-static DynArray<std::string>
-normalize_EditorLines(const DynArray<std::string>& lines)
-{
-    DynArray<std::string> out;
-    for (const std::string& line : lines) {
-        if (line.find_first_not_of(' ') == std::string::npos) continue;
-        std::istringstream in(line);
-        std::string joined, tk;
-        while (in >> tk) { if (!joined.empty()) joined += ' '; joined += tk; }
-        out.push_back(joined);
-    }
-    return out;
-}
-
-
-
 GameState::GameState(size_t starting_level)
 
 {
@@ -64,6 +47,50 @@ GameState::GameState(size_t starting_level)
     _highlight_sys   = cpputils::make_unique<CppHighlightSystem>();
 
     _wall_labels     = Wall::Label::generate_All(World::get_BoxScale());
+
+    _editor = Text::Handler(Wall::Role::Editor, World::get_BoxScale(),
+                            Text::LayoutMode::Snapped);
+    _editor.set_ShowNumbers(true, true);
+
+    _text_walls = { &_editor };
+
+    _program = Text::Handler(Wall::Role::Program, World::get_BoxScale(),
+                             Text::LayoutMode::Snapped);
+    _program.set_Lexing(false);
+    _program.set_ShowNumbers(false, false);
+    _program.set_DefaultStyle(Text3d::depth_wall, 0.003f,
+                              static_cast<double>(ProgramSystem::line_height),
+                              ConfigWall::free_space_dist);
+    _program.set_AppendBaseline(static_cast<double>(ProgramSystem::start_y));
+    _program.set_FaceMode(Text::FaceMode::Pitch);
+
+    _compiler = Text::Handler(Wall::Role::Compiler, World::get_BoxScale(),
+                              Text::LayoutMode::Snapped);
+    _compiler.set_Lexing(false);
+    _compiler.set_ShowNumbers(false, false);
+    _compiler.set_DefaultStyle(Text3d::depth_compiler, 0.003f,
+                               static_cast<double>(CompilerSystem::line_height),
+                               ConfigWall::free_space_dist);
+    _compiler.set_AppendBaseline(static_cast<double>(CompilerSystem::start_y));
+    _compiler.set_AppendColor(ConfigColor::white);
+    _compiler.set_FaceMode(Text::FaceMode::Pitch);
+
+    _info = Text::Handler(Wall::Role::Info, World::get_BoxScale(),
+                          Text::LayoutMode::Snapped);
+    _info.set_Lexing(false);
+    _info.set_ShowNumbers(false, false);
+    _info.set_DefaultStyle(Text3d::depth_wall, 0.003f,
+                           static_cast<double>(ConfigWall::text_line_spacing),
+                           ConfigWall::free_space_dist);
+    _info.set_AppendBaseline(World::get_FloorY<double>() + 1.0);
+    _info.set_AppendColor(ConfigColor::white);
+    _info.set_FaceMode(Text::FaceMode::Pitch);
+
+    _text_walls = { &_editor, &_program, &_compiler, &_info };
+
+    _ctx.editor_wall = &_editor;
+    _ctx.info_wall   = &_info;
+
     _pult_sys        = cpputils::make_unique<PultSystem>  ();
     _pult_sys->init(_ctx.scenery, _ctx.buttons, World::get_BoxScale());
 
@@ -79,21 +106,6 @@ GameState::GameState(size_t starting_level)
 
     if (_level_director->get_Runner().is_DynamicLib())
         sandbox_LoadFileToWall();
-
-    /*
-    _ctx.scenery.push_back(
-        cpputils::make_unique<SolidBox>(
-        Vec3{0, -15, -5},
-        Vec3{1.0, 0.5, 1.0}
-    ));
-    _ctx.scenery.push_back(
-        cpputils::make_unique<SolidBox>(
-        Vec3{3, -15, -5},
-        Vec3{1.0, 0.5, 1.0}
-    ));
-    _ctx.scenery.back()->body.mode = PhysicsMode::Dynamic;
-    */
-    //debug_SpawnWords(0);
 }
 
 
@@ -137,13 +149,11 @@ GameState::carriedline_Update()
     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
-    // position toward its live slot. Yaw snaps to live (cheap, reads fine).
     const bool easing = fs.lerping && !_freelook_return_from.empty();
     double s = 1.0;
     if (easing) {
         double t = fs.lerp_t; t = (t < 0.0) ? 0.0 : (t > 1.0 ? 1.0 : t);
-        s = t * t * (3.0 - 2.0 * t);   // smoothstep
+        s = t * t * (3.0 - 2.0 * t);
     }
 
     for (size_t i = 0; i < nodes.size() && i < slots.size(); ++i) {
@@ -182,7 +192,8 @@ GameState::pult_DispatchAction(PultAction act)
 void
 GameState::on_Click()
 {
-    PickResult r = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery, _ctx.wall_texts);
+    PickResult r = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery,
+                                    _ctx.wall_texts, _text_walls);
     if (!r.hit()) return;
 
     if (r.kind == PickKind::Scenery && r.scenery_obj) {
@@ -195,13 +206,22 @@ GameState::on_Click()
 bool
 GameState::on_Pick()
 {
-    PickResult r = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery, _ctx.wall_texts);
+    PickResult r = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery,
+                                    _ctx.wall_texts, _text_walls);
     if (!r.hit()) return false;
 
-    if (r.kind == PickKind::Wall) {
-        if (_ctx.wall_texts[r.wall_index] && _ctx.wall_texts[r.wall_index]->text3d)
-            _editor_wall.on_WordRemoved(_ctx.wall_texts[r.wall_index]->text3d.get());
+    if (r.kind == PickKind::WallWord && r.wall_handler && r.wall_token) {
+        Text3dPtr base = r.wall_handler->detach(r.wall_token);
+        if (!base) return false;
 
+        _level_director->record_PickedUp(base->text);
+        base->set_Look(ConfigColor::keep, Text3d::depth_floor);
+        Wall::Text::show_TokenInfo(_info, base->text);
+        carry_BeginAnim(std::move(base), true);
+        return true;
+    }
+
+    if (r.kind == PickKind::Wall) {
         Text3dPtr base = std::move(_ctx.wall_texts[r.wall_index]->text3d);
         _ctx.remove_WallText(r.wall_index);
         if (!base) return false;
@@ -209,7 +229,7 @@ GameState::on_Pick()
         if (base->type == ObjectType::Text) {
             _level_director->record_PickedUp(base->text);
             base->set_Look(ConfigColor::keep, Text3d::depth_floor);
-            Wall::Text::show_TokenInfo(_ctx.wall_texts, base->text);
+            Wall::Text::show_TokenInfo(_info, base->text);
         }
         carry_BeginAnim(std::move(base), true);
         return true;
@@ -222,7 +242,7 @@ GameState::on_Pick()
 
         _level_director->record_PickedUp(word->text);
         word->set_Look(ConfigColor::keep, Text3d::depth_floor);
-        Wall::Text::show_TokenInfo(_ctx.wall_texts, word->text);
+        Wall::Text::show_TokenInfo(_info, word->text);
 
         carry_BeginAnim(std::move(word), false);
         return true;
@@ -249,7 +269,7 @@ GameState::on_DropLast()
     _anims_drop.push_back(std::move(drop));
 
     if (!_carried.empty())
-        Wall::Text::show_TokenInfo(_ctx.wall_texts, _carried.last->word->text);
+        Wall::Text::show_TokenInfo(_info, _carried.last->word->text);
     else
         _level_director->show_Details(_ctx);
 
@@ -274,12 +294,12 @@ GameState::on_ShootOne()
     single.push_back(std::move(word));
 
     Aim::Result aim = Aim::crosshair_Target(*_ctx.camera, _ctx.words, _ctx.scenery,
-                                             _ctx.wall_texts, World::get_BoxScale());
+                                             _ctx.wall_texts, _text_walls, World::get_BoxScale());
     _projectiles_sys->launch(_projectiles, std::move(single),
                              _ctx.camera->fwd, _ctx.camera->pos, aim);
 
     if (!_carried.empty() && _carried.last && _carried.last->word)
-        Wall::Text::show_TokenInfo(_ctx.wall_texts, _carried.last->word->text);
+        Wall::Text::show_TokenInfo(_info, _carried.last->word->text);
     else _level_director->show_Details(_ctx);
 
     _level_director->record_Shot();
@@ -305,7 +325,7 @@ GameState::on_ShootAll()
     _carried = CarriedLine();
     if (!all_words.empty()) {
         Aim::Result aim = Aim::crosshair_Target(*_ctx.camera, _ctx.words, _ctx.scenery,
-                                                 _ctx.wall_texts, World::get_BoxScale());
+                                                 _ctx.wall_texts, _text_walls, World::get_BoxScale());
         _projectiles_sys->launch(_projectiles, std::move(all_words),
                                  _ctx.camera->fwd, _ctx.camera->pos, aim);
     }
@@ -315,8 +335,17 @@ GameState::on_ShootAll()
 }
 
 
+std::string
+GameState::editor_Source() const
+{
+    std::string src;
+    for (const std::string& l : _editor.as_Lines()) { src += l; src += '\n'; }
+    return src;
+}
+
+
 void GameState::on_Compile() {
-    std::string src = Wall::Text::extract_Text(_ctx.wall_texts, Wall::Role::Editor);
+    std::string src = editor_Source();
 
     if ( !_level_director->is_LevelComplete() &&
           _level_director->get_Runner().is_DynamicLib()) {
@@ -327,10 +356,10 @@ void GameState::on_Compile() {
         if (!path.empty() && !stripped.empty())
             SourceTokens::write_File(path, src);
 
-        _dyn_loader_sys->build(_ctx.wall_texts, src, true);
+        _dyn_loader_sys->build(_compiler, src, true);
     }
     else {
-        _compiler_sys->compile(_ctx.wall_texts, src);
+        _compiler_sys->compile(_compiler, src);
     }
 }
 
@@ -338,14 +367,14 @@ void GameState::on_Compile() {
 void
 GameState::on_Run()
 {
-    _program_sys->run(_ctx.wall_texts, World::get_BoxScale());
+    _program_sys->run(_program);
 }
 
 
 void
 GameState::on_DebugPrint()
 {
-    std::print("TEXT::: {0}\n", Wall::Text::extract_Text(_ctx.wall_texts, Wall::Role::Program));
+    std::print("TEXT::: {0}\n", editor_Source());
     for (Wall::Ptr& wt : _ctx.wall_texts)
         if (wt->text3d->type == ObjectType::Text)
             SDL_Log("wall text '%s' y=%.3f", wt->text3d->text.c_str(), wt->text3d->pos.y);
@@ -440,7 +469,7 @@ GameState::projectile_FeedProgramInput(ProjectileResult& result)
     if (!line.empty())
         _program_sys->feed_Input(line);
 
-    _program_sys->echo_Words(_ctx.wall_texts, result.words, World::get_BoxScale());
+    _program_sys->echo_Words(_program, result.words);
 }
 
 
@@ -460,61 +489,103 @@ GameState::projectile_LandOnFloor(ProjectileResult& result)
 void
 GameState::projectile_LandOnWall(ProjectileResult& result)
 {
-    DynArray<Wall::Ptr> placed;
-    DynArray<Text3d*>     editor_group;
-    placed.reserve(result.words.size());
+    DynArray<Text3dPtr> editor_block;
+    std::string prog_line, comp_line, info_line;
 
     for (Text3dPtr& w : result.words) {
         if (!w) continue;
         if (w->type == ObjectType::Text)
             w->set_Look(ConfigColor::keep, Text3d::depth_wall);
-
         w->body.mode = PhysicsMode::Static;
         w->body.vel  = Vec3{0, 0, 0};
 
         Wall::Ptr wt = Wall::Text::from_Drop(std::move(w));
-        if (wt && wt->text3d && wt->role == Wall::Role::Editor)
-            editor_group.push_back(wt->text3d.get());
-        placed.push_back(std::move(wt));
+        if (!wt || !wt->text3d) continue;
+
+        switch (wt->role) {
+            case Wall::Role::Editor:
+                editor_block.push_back(std::move(wt->text3d));
+                break;
+            case Wall::Role::Program:
+                if (!prog_line.empty()) prog_line += ' ';
+                prog_line += wt->text3d->text;
+                break;
+            case Wall::Role::Compiler:
+                if (!comp_line.empty()) comp_line += ' ';
+                comp_line += wt->text3d->text;
+                break;
+            case Wall::Role::Info:
+                if (!info_line.empty()) info_line += ' ';
+                info_line += wt->text3d->text;
+                break;
+        }
     }
 
-    if (!editor_group.empty()) {
+    if (!editor_block.empty()) {
         Vec3 impact{0, 0, 0};
-        for (Text3d* t : editor_group)
+        for (const Text3dPtr& t : editor_block)
             { impact.x += t->pos.x; impact.y += t->pos.y; impact.z += t->pos.z; }
-        const double inv = 1.0 / static_cast<double>(editor_group.size());
+        const double inv = 1.0 / static_cast<double>(editor_block.size());
         impact.x *= inv; impact.y *= inv; impact.z *= inv;
-        _editor_wall.on_LinePlaced(editor_group, impact);
+        _editor.attach(std::move(editor_block), impact);
     }
-
-    for (Wall::Ptr& wt : placed)
-        _ctx.wall_texts.push_back(std::move(wt));
-
-    if (!editor_group.empty())
-        editor_SyncFile();
+    if (!prog_line.empty()) _program .append_Line(prog_line);
+    if (!comp_line.empty()) _compiler.append_Line(comp_line);
+    if (!info_line.empty()) _info    .append_Line(info_line);
 }
 
 
 void
 GameState::projectile_LandOnWallText(ProjectileResult& result,
-                                      DynArray<size_t>& wall_text_to_erase)
+                                     DynArray<size_t>& wall_text_to_erase)
 {
-    if (Wall::Editor::is_LineNumberHit(result.wall_text_index)) {
-        const size_t lid   = Wall::Editor::line_Of(result.wall_text_index);
-        const double delta = _editor_wall.scroll_DeltaToEye(lid, _ctx.camera->pos.y);
+    const size_t idx = result.wall_text_index;
 
-        for (Wall::Ptr& wt : _ctx.wall_texts)
-            if (wt && wt->text3d && wt->role == Wall::Role::Editor)
-                wt->text3d->pos.y += delta;
-        _editor_wall.scroll(delta);
-        _editor_wall.bounce(lid);
+    if (idx >= LABEL_HIT_BASE) {
+        for (Text3dPtr& w : result.words)
+            if (w) _ctx.drop_WordToFloor(std::move(w), result.vel);
+        return;
+    }
 
-        projectile_LandOnFloor(result);
+    if (idx >= WALL_TOK_BASE) {
+        const size_t rel = idx - WALL_TOK_BASE;
+        const size_t h   =  rel >> 28;
+        const size_t li  = (rel >> 14) & 0x3FFF;
+        const size_t ti  =  rel        & 0x3FFF;
+        Text::Handler* hw = (h < _text_walls.size()) ? _text_walls[h] : nullptr;
+
+        if (hw) {
+            const auto& lines = hw->get_Lines();
+            if (li < lines.size() && ti < lines[li].tokens.size() && lines[li].tokens[ti]) {
+                if (hw->edit_Active()) {
+                    const Vec3 impact = lines[li].tokens[ti]->pos;
+                    DynArray<Text3dPtr> block;
+                    for (Text3dPtr& w : result.words) if (w) block.push_back(std::move(w));
+                    hw->attach(std::move(block), impact);
+                    return;
+                }
+                if (Text3dPtr orphan = hw->detach(lines[li].tokens[ti].get())) {
+                    orphan->body.hover_height = World::get_FloorY<double>() + 1.5;
+                    orphan->yaw               = 0.0f;
+                    _ctx.drop_WordToFloor(std::move(orphan), Vec3{0.0, 1.5, 0.0});
+                }
+            }
+        }
+        for (Text3dPtr& w : result.words)
+            if (w) _ctx.drop_WordToFloor(std::move(w), result.vel);
+        return;
     }
-    else {
-        walltext_HandleShootDown(result, wall_text_to_erase);
+
+    if (idx >= Text::Handler::HIT_BASE) {
+        const size_t lid   = Text::Handler::line_Of(idx);
+        const double delta = _editor.scroll_DeltaToEye(lid, _ctx.camera->pos.y);
+        _editor.scroll(delta);
+        _editor.bounce(lid);
+        projectile_LandOnFloor(result);
+        return;
     }
-    return;
+
+    walltext_HandleShootDown(result, wall_text_to_erase);
 }
 
 
@@ -522,13 +593,9 @@ void
 GameState::walltext_HandleShootDown(ProjectileResult& result,
                                   DynArray<size_t>& to_erase)
 {
-    bool removed_editor = false;
     if (result.wall_text_index < _ctx.wall_texts.size()) {
         Wall::Ptr& wt = _ctx.wall_texts[result.wall_text_index];
         if (wt && wt->text3d) {
-            removed_editor = (wt->role == Wall::Role::Editor);
-            _editor_wall.on_WordRemoved(wt->text3d.get());
-
             Text3dPtr orphan = std::move(wt->text3d);
             orphan->body.hover_height = World::get_FloorY<double>() + 1.5;
             orphan->yaw               = 0.0f;
@@ -541,9 +608,6 @@ GameState::walltext_HandleShootDown(ProjectileResult& result,
         if (!w) continue;
         _ctx.drop_WordToFloor(std::move(w), result.vel);
     }
-
-    if (removed_editor)
-        editor_SyncFile();
 }
 
 
@@ -557,50 +621,41 @@ void
 GameState::handle_Input(FrameInput& input, double dt) {
     for (InputCommand cmd : input.commands) {
         switch (cmd) {
-            case InputCommand::Jump:          on_Jump();            break;
-            case InputCommand::Click:         on_Click();           break;
-            case InputCommand::Drop:          on_DropLast();        break;
-            case InputCommand::ShootAll:      on_ShootAll();        break;
-            case InputCommand::Compile:       on_Compile();         break;
-            case InputCommand::Run:           on_Run();             break;
-            case InputCommand::DebugPrint:    on_DebugPrint();      break;
-            case InputCommand::FreelookStart: on_FreelookStart();   break;
-            case InputCommand::FreelookEnd:   on_FreelookEnd();     break;
-            case InputCommand::NumberInc:     on_NumberAdjust(+1);  break;
-            case InputCommand::NumberDec:     on_NumberAdjust(-1);  break;
-            case InputCommand::TypingStart:   on_TypingStart (input); break;
-            case InputCommand::TypingCommit:  on_TypingCommit(input); break;
-            case InputCommand::TypingCancel:  on_TypingCancel(input); break;
-            case InputCommand::SandboxRestore:    sandbox_Restore();  break;
+            case InputCommand::Jump:          on_Jump();                  break;
+            case InputCommand::Click:         on_Click();                 break;
+            case InputCommand::Drop:          on_DropLast();              break;
+            case InputCommand::ShootAll:      on_ShootAll();              break;
+            case InputCommand::Compile:       on_Compile();               break;
+            case InputCommand::Run:           on_Run();                   break;
+            case InputCommand::DebugPrint:    on_DebugPrint();            break;
+            case InputCommand::FreelookStart: on_FreelookStart();         break;
+            case InputCommand::FreelookEnd:   on_FreelookEnd();           break;
+            case InputCommand::NumberInc:     on_NumberAdjust(+1);        break;
+            case InputCommand::NumberDec:     on_NumberAdjust(-1);        break;
+            case InputCommand::TypingStart:   on_TypingStart (input);     break;
+            case InputCommand::TypingCommit:  on_TypingCommit(input);     break;
+            case InputCommand::TypingCancel:  on_TypingCancel(input);     break;
+            case InputCommand::SandboxRestore:    sandbox_Restore();      break;
             case InputCommand::ConnectPultSubmit: on_WallConnectSubmit(); break;
             case InputCommand::ConnectPultExit:   on_WallConnectExit();   break;
         }
     }
 
     if (_wall_connection.get_Target() == Wall::Connection::Target::Editor) {
-        Wall::Connection::Request req = _wall_connection.feed_Vim(input.vim_keys, _ctx.camera->pos.y);
+        Text::Handler::EditReq req = _editor.feed_Keys(input.vim_keys, _ctx.camera->pos.y);
         input.vim_keys.clear();
         switch (req) {
-            case Wall::Connection::Request::Write:
-                editor_CommitToWall(_wall_connection.get_Lines(), false);
-                _wall_connection.mark_Saved();
+            case Text::Handler::EditReq::Write:
+                _editor.mark_Saved();
                 break;
-            case Wall::Connection::Request::WriteQuit:
-                editor_CommitToWall(_wall_connection.get_Lines(), true);
+            case Text::Handler::EditReq::WriteQuit:
+            case Text::Handler::EditReq::Exit:
+            case Text::Handler::EditReq::Discard:
+                _editor.end_Edit();
                 _wall_connection.end();
                 _frame_input.capture_vim = false;
                 break;
-            case Wall::Connection::Request::Exit:
-                editor_CommitToWall(_wall_connection.get_Lines(), true);
-                _wall_connection.end();
-                _frame_input.capture_vim = false;
-                break;
-            case Wall::Connection::Request::QuitDiscard:
-                editor_CommitToWall(_wall_connection.get_Lines(), true);
-                _wall_connection.end();
-                _frame_input.capture_vim = false;
-                break;
-            case Wall::Connection::Request::None:
+            case Text::Handler::EditReq::None:
                 break;
         }
     }
@@ -650,7 +705,6 @@ GameState::update(double dt, const GlRenderer* gl)
     }
 
     { FP_ZONE("update.post_step");
-
         for (Wall::Ptr& wt : _ctx.wall_texts) {
             if (!wt || !wt->text3d) continue;
             wt->text3d->pitch = Wall::Text::tilt_TowardEye(wt->text3d->pos, _ctx.camera->pos);
@@ -665,7 +719,9 @@ GameState::update(double dt, const GlRenderer* gl)
 
         _pult_sys->  update(dt, *_ctx.camera);
         _plate_sys-> update(dt, *_ctx.player, _level_director->get_StepPlates());
-        _editor_wall.update(dt, _ctx.camera->pos);
+
+        for (Text::Handler* h : _text_walls)
+            if (h) h->update(dt, *_ctx.camera);
 
         _wall_connection.update(dt, *_ctx.camera, gl->get_Aspect(), gl->get_Fov_y());
     }
@@ -680,13 +736,13 @@ GameState::update(double dt, const GlRenderer* gl)
     }
 
     { FP_ZONE("update._compiler_sys");
-        _compiler_sys->update(_ctx.wall_texts);
+        _compiler_sys->update(_compiler);
         if (_compiler_sys->last_compile_ok) {
             _compiler_sys->last_compile_ok = false;
             _level_director->record_CompileOk();
         }
 
-        _dyn_loader_sys->update(_ctx.wall_texts);
+        _dyn_loader_sys->update(_compiler);
         if (_dyn_loader_sys->last_build_ok) {
             _dyn_loader_sys->last_build_ok = false;
             dyn_Apply(_dyn_loader_sys->load_Objects());
@@ -695,7 +751,7 @@ GameState::update(double dt, const GlRenderer* gl)
     }
 
     { FP_ZONE("update._program_sys");
-        _program_sys->update(_ctx.wall_texts, World::get_BoxScale());
+        _program_sys->update(_program);
     }
 
     { FP_ZONE("update.goals");
@@ -704,10 +760,13 @@ GameState::update(double dt, const GlRenderer* gl)
     }
 
     { FP_ZONE("update.highlight");
+        if (_editor.is_Dirty())
+            _highlight_sys->request(_editor.render_Source(), _editor.generation());
+
         DynArray<HiSpan> spans;
         uint64_t gen;
-        if (_highlight_sys->poll(spans, gen) && gen == _editor_gen)
-            _editor_file.apply_Spans(spans);
+        if (_highlight_sys->poll(spans, gen))
+            _editor.apply_Highlight(spans, gen);
     }
 
     input.look_x = 0;
@@ -736,9 +795,9 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
 
     { FP_ZONE("render.world");        _ctx.world ->draw(renderer, *_ctx.camera, *_ctx.light); }
 
-    { FP_ZONE("render._editor_wall");
-        if (!_editor_hidden)
-            _editor_wall.draw(renderer, *_ctx.camera, *_ctx.light);
+    { FP_ZONE("render.text_walls");
+        for (Text::Handler* t : _text_walls)
+            if (t) t->render(renderer, *_ctx.camera, *_ctx.light);
     }
 
     { FP_ZONE("render.scenery");
@@ -752,10 +811,8 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
     }
 
     { FP_ZONE("render.wall_texts");
-        for (const Wall::Ptr& wt : _ctx.wall_texts) {
-            if (_editor_hidden && wt && wt->role == Wall::Role::Editor) continue;
+        for (const Wall::Ptr& wt : _ctx.wall_texts)
             wt->draw(renderer, *_ctx.camera, *_ctx.light);
-        }
     }
 
     { FP_ZONE("render.wall_labels");
@@ -803,7 +860,7 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
 void
 GameState::infowall_Clear()
 {
-    _ctx.clear_WallRole(Wall::Role::Info);
+    _info.clear();
 }
 
 
@@ -842,10 +899,10 @@ GameState::update_WorldSimulation(double dt)
 
     DynArray<ProjectileContactEvent> projectile_contacts;
 
-    DynArray<TextLabel*> all_labels = _pult_sys->get_Labels();
+    DynArray<Text::Handler*> all_labels = _pult_sys->get_Labels();
     { FP_ZONE("updateworldsim.hint_and_labels");
         _hint_sys->update(dt, *_ctx.camera);
-        for (TextLabel* lbl : all_labels)
+        for (Text::Handler* lbl : all_labels)
             lbl->update(dt, *_ctx.camera);
 
         all_labels.push_back(&_hint_sys->get_Label());
@@ -871,7 +928,25 @@ GameState::update_WorldSimulation(double dt)
                 });
         }
 
-        for (const Wall::Editor::NumberHit& nh : _editor_wall.number_Hitboxes())
+        for (size_t h = 0; h < _text_walls.size(); ++h) {
+            Text::Handler* hw = _text_walls[h];
+            if (!hw) continue;
+            const auto& elines = hw->get_Lines();
+            for (size_t li = 0; li < elines.size(); ++li) {
+                const DynArray<Text::TokenPtr>& toks = elines[li].tokens;
+                for (size_t ti = 0; ti < toks.size(); ++ti) {
+                    if (!toks[ti]) continue;
+                    wall_text_hits.push_back({
+                            Wall::Position::NegX,
+                            WALL_TOK_BASE | (h << 28) | (li << 14) | ti,
+                            toks[ti]->aabb_Min(),
+                            toks[ti]->aabb_Max(),
+                        });
+                }
+            }
+        }
+
+        for (const Text::Handler::NumberHit& nh : _editor.number_Hitboxes())
             wall_text_hits.push_back({ Wall::Position::NegX, nh.index,
                                        nh.aabb_Min, nh.aabb_Max });
 
@@ -879,7 +954,6 @@ GameState::update_WorldSimulation(double dt)
             const DynArray<Text3dPtr>& words = _wall_labels[li].label.get_Words();
             for (size_t wi = 0; wi < words.size(); ++wi) {
                 if (!words[wi]) continue;
-                constexpr size_t LABEL_HIT_BASE = size_t(1) << 48;
                 wall_text_hits.push_back({
                         Wall::position_Of(_wall_labels[li].role),
                         LABEL_HIT_BASE | (li << 16) | wi,
@@ -1002,7 +1076,6 @@ GameState::dyn_Apply(const DynArray<DynObject>& objs)
 {
     dyn_Clear();
 
-    // Build fresh objects from the descriptions.
     for (const DynObject& o : objs) {
         if (o.kind == DYN_BOX) {
             auto box = cpputils::make_unique<SolidBox>(
@@ -1014,11 +1087,11 @@ GameState::dyn_Apply(const DynArray<DynObject>& objs)
             _dyn_scenery.push_back(box.get());
             _ctx.scenery.push_back(std::move(box));
         }
-        else { // DYN_TEXT
+        else {
             Text3dPtr t = make_Text3d(o.text, Text3d::depth_floor, 0.003f,
                                       Vec3{ o.x, o.y, o.z },
                                       SDL_FColor{ o.r, o.g, o.b, 1.0f });
-            t->body.mode = PhysicsMode::Static;   // lib text just sits where placed
+            t->body.mode = PhysicsMode::Static;
 
             _dyn_scenery.push_back(t.get());
             _ctx.words.push_back(std::move(t));
@@ -1049,8 +1122,6 @@ GameState::sandbox_LoadFileToWall()
         return;
     }
 
-    editor_Clear();
-
     DynArray<std::string> lines;
     for (const auto& toks : SourceTokens::tokenize_Lines(src)) {
         std::string joined;
@@ -1058,7 +1129,7 @@ GameState::sandbox_LoadFileToWall()
         lines.push_back(joined);
     }
 
-    editor_PlaceLines(lines);
+    _editor.set_Lines(lines);
 }
 
 
@@ -1140,15 +1211,12 @@ GameState::wallconnect_BeginProgram()
 void
 GameState::wallconnect_BeginEditor()
 {
-    DynArray<std::string> lines = editor_ReadLines();
-    _editor_absorbed = normalize_EditorLines(lines);
-
-    _editor_hidden = true;
+    _editor.begin_Edit(_ctx.camera->pos.y);
 
     _frame_input.capture_vim = true;
     _frame_input.vim_keys.clear();
 
-    _wall_connection.begin_Editor(_pult_sys->get_ConnectEditorPos(), lines, _ctx.camera->pos.y);
+    _wall_connection.begin_Editor(_pult_sys->get_ConnectEditorPos(), _editor);
 }
 
 
@@ -1164,12 +1232,12 @@ GameState::on_WallConnectSubmit()
         for (const char* h : { ":exit            leave the connection",
                                "ctrl+WASD        move while connected",
                                "<text> + Enter   send a line to the program (stdin)" })
-            _program_sys->echo_Input(_ctx.wall_texts, h);
+            _program_sys->echo_Input(_program, h);
         return;
     }
 
     _program_sys->feed_Input(line);
-    _program_sys->echo_Input(_ctx.wall_texts, line);
+    _program_sys->echo_Input(_program, line);
 }
 
 
@@ -1186,165 +1254,12 @@ GameState::on_WallConnectExit()
 void
 GameState::on_EditorClear()
 {
-    Wall::Text::clear_ToFloor(_ctx.wall_texts, _ctx.words, Wall::Role::Editor);
-    _editor_wall.clear();
+    _editor.clear_ToFloor(_ctx.words);
 }
 
 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()
-{
-    _editor_wall.clear();
-    _ctx.clear_WallRole(Wall::Role::Editor);
-}
-
-
-DynArray<std::string>
-GameState::editor_AbsorbLines()
-{
-    std::string src = Wall::Text::extract_Text(_ctx.wall_texts, Wall::Role::Editor);
-    DynArray<std::string> lines;
-
-    std::string cur;
-    for (char ch : src) {
-        if (ch == '\n') { lines.push_back(cur); cur.clear(); }
-        else            { cur.push_back(ch); }
-    }
-    if (!cur.empty()) lines.push_back(cur);
-
-    editor_Clear();
-    return lines;
-}
-
-
-DynArray<std::string>
-GameState::editor_ReadLines() const
-{
-    std::string src = Wall::Text::extract_Text(_ctx.wall_texts, Wall::Role::Editor);
-    DynArray<std::string> lines;
-    std::string cur;
-    for (char ch : src) {
-        if (ch == '\n') { lines.push_back(cur); cur.clear(); }
-        else            { cur.push_back(ch); }
-    }
-    if (!cur.empty()) lines.push_back(cur);
-    return lines;
-}
-
-
-void
-GameState::editor_PlaceLines(const DynArray<std::string>& lines)
-{
-    const float     ws     = World::get_BoxScale();
-    const double    x_wall = -static_cast<double>(ws) + 0.05;
-    Wall::Transform tf     = Wall::get_Transform(Wall::Role::Editor, ws);
-
-    const double lh      = ConfigWall::line_height;
-    const double gap     = 0.5;
-    const double floor_y = World::get_FloorY<double>();
-
-    size_t n_lines = 0;
-    for (const std::string& l : lines)
-        if (l.find_first_not_of(' ') != std::string::npos) 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;
-
-    struct Tok { Text3dPtr t; double half; };
-    struct Row { DynArray<Tok> toks; double width; double y; };
-    DynArray<Row> rows;
-
-    double max_width = 0.0;
-    for (const std::string& line : lines) {
-        if (line.find_first_not_of(' ') == std::string::npos)
-            continue;
-
-        Row row;
-        row.y = y;
-        double total_w = 0.0;
-
-        std::istringstream in(line);
-        std::string        tk;
-        while (in >> tk) {
-            Text3dPtr t = make_Text3d(tk, Text3d::depth_wall, 0.003f,
-                                      Vec3{ x_wall, y, 0.0 }, ConfigColor::wall);
-            t->yaw = tf.yaw;
-            const double half = static_cast<double>(t->half_extent);
-            total_w += 2.0 * half;
-            row.toks.push_back({ std::move(t), half });
-        }
-        if (row.toks.empty()) { y -= lh; continue; }
-        total_w += gap * static_cast<double>(row.toks.size() - 1);
-        row.width = total_w;
-        max_width = SDL_max(max_width, total_w);
-
-        rows.push_back(std::move(row));
-        y -= lh;
-    }
-    if (rows.empty()) return;
-
-    const double left_z = max_width * 0.5;
-
-    for (Row& row : rows) {
-        DynArray<Text3d*> placed;
-        double cursor = left_z;
-        for (Tok& tok : row.toks) {
-            const double center_z = cursor - tok.half;
-            tok.t->pos = Vec3{ x_wall, row.y, center_z };
-            placed.push_back(tok.t.get());
-            _ctx.add_WallText(Wall::Position::NegX, Wall::Role::Editor, std::move(tok.t));
-            cursor = center_z - tok.half - gap;
-        }
-        _editor_wall.add_PlacedLine(placed, row.y);
-    }
-
-    editor_SyncFile();
-}
-
-
-void
-GameState::editor_CommitToWall(const DynArray<std::string>& lines, bool ending)
-{
-    DynArray<std::string> incoming = normalize_EditorLines(lines);
-
-    bool same = (incoming.size() == _editor_absorbed.size());
-    if (same)
-        for (size_t i = 0; i < incoming.size(); ++i)
-            if (incoming[i] != _editor_absorbed[i]) { same = false; break; }
-
-    if (ending) _editor_hidden = false;
-
-    if (same) return;
-
-    editor_Clear();
-    editor_PlaceLines(lines);
-    _editor_absorbed = incoming;
-}
-
-void
-GameState::editor_SyncFile()
-{
-    _editor_file.clear();
-    for (const DynArray<Text3d*>& line : _editor_wall.lines_InOrder()) {
-        Text::Line fl;
-        for (Text3d* w : line)
-            if (w) fl.tokens.push_back(Text::Token{ w->text, 0, 0, w });
-        _editor_file.lines.push_back(std::move(fl));
-    }
-
-    ++_editor_gen;
-    _highlight_sys->request(_editor_file.render_Source(), _editor_gen);
+    if (dt > 0) _editor.scroll( ConfigWall::scroll_step);
+    else        _editor.scroll(-ConfigWall::scroll_step);
 }
diff --git a/src/states/game_state.hpp b/src/states/game_state.hpp
index 573abcf..7185ecd 100644
--- a/src/states/game_state.hpp
+++ b/src/states/game_state.hpp
@@ -24,10 +24,10 @@
 #include "systems/wall_connection.hpp"
 #include "systems/word_ai_system.hpp"
 #include "systems/word_anim.hpp"
-#include "text_3d/vim_buffer.hpp"
+#include "text/handler.hpp"          // Text::Handler (replaces Wall::Editor + Text::SrcView)
+#include "text/vim_buffer.hpp"
 #include "utils/globals.hpp"
-#include "utils/text.hpp"
-#include "wall/editor.hpp"
+#include "wall/label.hpp"
 #include "wall/wall.hpp"
 
 
@@ -55,41 +55,37 @@ class GameState : public IState
     cpputils::unique_ptr<WordAiSystem>       _word_ai_sys;
 
 
-    bool     _editor_hidden  { false };
-    uint64_t _editor_gen     { 0 };
     double   _pick_cooldown  { 0.0 };
     double   _shoot_cooldown { 0.0 };
 
     std::string        _typing_last;
 
     CarriedLine        _carried;
-    Wall::Editor       _editor_wall;
+    Text::Handler      _editor;
+    Text::Handler      _program;
+    Text::Handler      _compiler;
+    Text::Handler      _info;
+    Wall::Connection   _wall_connection;
     FrameInput         _frame_input;
     GameContext        _ctx;
-    Wall::Connection     _wall_connection;
-    Text::View         _editor_file;
 
+    DynArray<GameObject*>    _dyn_scenery;
+    DynArray<Text3d*>        _typing_words;
+    DynArray<Vec3>           _freelook_return_from;
+    DynArray<Projectile>     _projectiles;
+    DynArray<Wall::Label>    _wall_labels;
+    DynArray<WordAnim_Add>   _anims_add;
+    DynArray<WordAnim_Drop>  _anims_drop;
+    DynArray<Text::Handler*> _text_walls;
 
-    DynArray<GameObject*>   _dyn_scenery;
-    DynArray<Text3d*>       _typing_words;
-    DynArray<Vec3>          _freelook_return_from;
-    DynArray<std::string>   _editor_absorbed;
-    DynArray<Projectile>    _projectiles;
-    DynArray<Wall::Label>     _wall_labels;
-    DynArray<WordAnim_Add>  _anims_add;
-    DynArray<WordAnim_Drop> _anims_drop;
+    // editor-token projectile-hit id range (line numbers use Text::Handler::HIT_BASE)
+    static constexpr size_t WALL_TOK_BASE  = size_t(1) << 40;
+    static constexpr size_t LABEL_HIT_BASE = size_t(1) << 48;
 
     void wallconnect_BeginEditor ();
     void wallconnect_BeginProgram();
 
-    DynArray<std::string>
-         editor_AbsorbLines  ();
-    DynArray<std::string>
-         editor_ReadLines    () const;
-    void editor_Clear        ();
-    void editor_CommitToWall (const DynArray<std::string>& lines, bool ending);
-    void editor_PlaceLines   (const DynArray<std::string>& lines);
-    void editor_SyncFile     ();
+    std::string editor_Source() const;
 
 
     bool on_Pick         ();
diff --git a/src/systems/aim.hpp b/src/systems/aim.hpp
index cbb1641..2890238 100644
--- a/src/systems/aim.hpp
+++ b/src/systems/aim.hpp
@@ -5,6 +5,7 @@
 #include "entities/camera.hpp"
 #include "entities/game_object.hpp"
 #include "systems/collision.hpp"
+#include "text/handler.hpp"
 #include "text_3d/text_3d.hpp"
 #include "utils/globals.hpp"
 #include "wall/wall.hpp"
@@ -27,6 +28,7 @@ crosshair_Target(const Camera& camera,
                  const DynArray<Text3dPtr>&     words,
                  const DynArray<GameObjectPtr>& scenery,
                  const DynArray<Wall::Ptr>&   wall_texts,
+                 const DynArray<Text::Handler*>& walls,
                  float world_scale)
 {
     const Vec3   eye = camera.pos;
@@ -71,6 +73,12 @@ crosshair_Target(const Camera& camera,
     for (const Wall::Ptr& wt : wall_texts)
         if (wt && wt->text3d) test(wt->text3d->aabb_Min(), wt->text3d->aabb_Max(), nullptr);
 
+    for (const Text::Handler* h : walls)
+        if (h)
+            for (const Text::Line& line : h->get_Lines())
+                for (const Text::TokenPtr& tp : line.tokens)
+                    if (tp) test(tp->aabb_Min(), tp->aabb_Max(), nullptr);
+
     if (win) {
         out.track = win;
         const double dx = out.point.x - win->pos.x;
diff --git a/src/systems/compiler_system.cpp b/src/systems/compiler_system.cpp
index 35d5602..a520713 100644
--- a/src/systems/compiler_system.cpp
+++ b/src/systems/compiler_system.cpp
@@ -1,8 +1,6 @@
 #include <SDL3/SDL.h>
-#include <algorithm>
 
 #include "systems/compiler_system.hpp"
-#include "text_3d/text_3d.hpp"
 #include "utils/text.hpp"
 
 
@@ -13,7 +11,7 @@ CompilerSystem::~CompilerSystem()
 
 
 void
-CompilerSystem::compile(Wall::PtrList& texts, const std::string& src)
+CompilerSystem::compile(Text::Handler& out, const std::string& src)
 {
     // One compile at a time. Reap a finished worker; ignore re-triggers while
     // one is still running.
@@ -22,13 +20,7 @@ CompilerSystem::compile(Wall::PtrList& texts, const std::string& src)
 
     last_compile_ok = false;
 
-    // Clear previous output from the compiler wall (main thread — GL-owned).
-    texts.erase(
-        std::remove_if(texts.begin(), texts.end(),
-            [](const Wall::Ptr& wt) {
-                return wt->wall_position == Wall::position_Of(Wall::Role::Compiler);
-            }),
-        texts.end());
+    out.clear();      // wipe previous diagnostics from the compiler wall
     _count = 0;
 
     {
@@ -86,7 +78,7 @@ CompilerSystem::run_Compile(CompilerSystem* self, std::string src)
 
 
 void
-CompilerSystem::update(Wall::PtrList& texts)
+CompilerSystem::update(Text::Handler& out)
 {
     // Drain whatever the worker produced and render it (main thread, GL-safe).
     DynArray<std::string> lines;
@@ -97,30 +89,14 @@ CompilerSystem::update(Wall::PtrList& texts)
     }
 
     for (const std::string& line : lines)
-        push_Line(texts, line);
+        push_Line(out, line);
 }
 
 
 void
-CompilerSystem::push_Line(Wall::PtrList& texts, const std::string& line)
+CompilerSystem::push_Line(Text::Handler& out, const std::string& line)
 {
     if (line == "OK") last_compile_ok = true;
-
-    std::string clean = Text::ascii_Sanitize(line);
-
-    for (Wall::Ptr& wt : texts)
-        if (wt->role == Wall::Role::Compiler)
-            wt->text3d->pos.y += static_cast<double>(line_height);
-
-    Wall::Transform tf = Wall::get_Transform(Wall::Role::Compiler, World::get_BoxScale());
-
-    Vec3 pos = tf.position;
-    pos.y = start_y;
-
-    Text3dPtr t = make_Text3d(clean, Text3d::depth_compiler, 0.003f, pos, ConfigColor::white);
-    t->yaw = tf.yaw;
-
-    texts.push_back(cpputils::make_unique<Wall::Text>(Wall::Text{
-        tf.wall_position, Wall::Role::Compiler, std::move(t) }));
+    out.append_Line(Text::ascii_Sanitize(line));
     _count++;
 }
diff --git a/src/systems/compiler_system.hpp b/src/systems/compiler_system.hpp
index e835fd0..0f1df7a 100644
--- a/src/systems/compiler_system.hpp
+++ b/src/systems/compiler_system.hpp
@@ -7,6 +7,7 @@
 #include <thread>
 
 #include "entities/world.hpp"
+#include "text/handler.hpp"
 #include "utils/globals.hpp"
 
 
@@ -19,7 +20,7 @@ private:
     std::thread           _worker;
     DynArray<std::string> _pending;
 
-    void        push_Line  (Wall::PtrList&, const std::string& line);
+    void        push_Line  (Text::Handler& out, const std::string& line);
     static void run_Compile(CompilerSystem* self, std::string src);
 public:
     static inline float line_height = 0.4f;
@@ -27,8 +28,8 @@ public:
 
     bool last_compile_ok{false};
 
-    void update    (Wall::PtrList&);
-    void compile   (Wall::PtrList&, const std::string& src);
+    void update    (Text::Handler& out);
+    void compile   (Text::Handler& out, const std::string& src);
 
     bool is_Running() const { return _running.load(); }
 
diff --git a/src/systems/dyn_loader_system.cpp b/src/systems/dyn_loader_system.cpp
index 8109e3a..f9fb0c7 100644
--- a/src/systems/dyn_loader_system.cpp
+++ b/src/systems/dyn_loader_system.cpp
@@ -1,18 +1,13 @@
 #include <SDL3/SDL.h>
-#include <algorithm>
 #include <dlfcn.h>
 #include <string>
 #include <sys/select.h>
 
-#include "entities/world.hpp"
 #include "systems/dyn_loader_system.hpp"
-#include "text_3d/text_3d.hpp"
 #include "utils/text.hpp"
 
 
 // Where game_mod_abi.h is installed at runtime, and the build scratch dir.
-// The header must be reachable by the runtime gcc via this -I path. Adjust to
-// wherever your CMake installs it (see CMake notes).
 static constexpr const char* MOD_INCLUDE_DIR = "/tmp/game_mod_include";
 static constexpr const char* MOD_SRC_PATH    = "/tmp/game_mod.c";
 
@@ -64,17 +59,11 @@ DynLoaderSystem::wrap_Source(const std::string& user_body)
 
 
 void
-DynLoaderSystem::build(Wall::PtrList& texts, const std::string& user_src, bool full_source)
+DynLoaderSystem::build(Text::Handler& out, const std::string& user_src, bool full_source)
 {
     last_build_ok = false;
 
-    // Clear previous output from the Compiler wall.
-    texts.erase(
-        std::remove_if(texts.begin(), texts.end(),
-            [](const Wall::Ptr& wt) {
-                return wt->wall_position == Wall::position_Of(Wall::Role::Compiler);
-            }),
-        texts.end());
+    out.clear();      // wipe previous diagnostics from the compiler wall
     _count = 0;
 
     // Write wrapped source.
@@ -82,11 +71,11 @@ DynLoaderSystem::build(Wall::PtrList& texts, const std::string& user_src, bool f
     if (!f) {
         SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
                      "DynLoaderSystem: cannot write %s", MOD_SRC_PATH);
-        push_Line(texts, "internal error: cannot write source");
+        push_Line(out, "internal error: cannot write source");
         return;
     }
-    const std::string out = full_source ? user_src : wrap_Source(user_src);
-    fputs(out.c_str(), f);
+    const std::string src = full_source ? user_src : wrap_Source(user_src);
+    fputs(src.c_str(), f);
     fclose(f);
 
     // Unique output path each build — dlopen() caches handles by path, so a
@@ -110,7 +99,7 @@ DynLoaderSystem::build(Wall::PtrList& texts, const std::string& user_src, bool f
 
 
 void
-DynLoaderSystem::update(Wall::PtrList& texts)
+DynLoaderSystem::update(Text::Handler& out)
 {
     if (!_pipe) return;
 
@@ -127,14 +116,14 @@ DynLoaderSystem::update(Wall::PtrList& texts)
             if (_count == 0) {
                 last_build_ok = true;
                 _last_so_path = _pending_so;
-                push_Line(texts, "BUILD OK");
+                push_Line(out, "BUILD OK");
             }
             return;
         }
         std::string line(buf);
         if (!line.empty() && line.back() == '\n') line.pop_back();
         if (!line.empty())
-            push_Line(texts, line);
+            push_Line(out, line);
 
         FD_ZERO(&set); FD_SET(fd, &set);
     }
@@ -142,30 +131,13 @@ DynLoaderSystem::update(Wall::PtrList& texts)
 
 
 void
-DynLoaderSystem::push_Line(Wall::PtrList& texts, const std::string& line)
+DynLoaderSystem::push_Line(Text::Handler& out, const std::string& line)
 {
-    // Shift existing compiler lines up, drop the new one at the bottom.
-    for (Wall::Ptr& wt : texts)
-        if (wt->role == Wall::Role::Compiler)
-            wt->text3d->pos.y += static_cast<double>(line_height);
-
-    Wall::Transform tf = Wall::get_Transform(Wall::Role::Compiler, World::get_BoxScale());
-    Vec3 pos = tf.position;
-    pos.y = start_y;
-
-    std::string clean = Text::ascii_Sanitize(line);
-    Text3dPtr t = make_Text3d(clean, Text3d::depth_compiler, 0.003f,
-                              pos, ConfigColor::compiler);
-    t->yaw = tf.yaw;
-
-    texts.push_back(cpputils::make_unique<Wall::Text>(Wall::Text{
-        tf.wall_position,
-        Wall::Role::Compiler,
-        std::move(t)
-    }));
+    out.append_Line(Text::ascii_Sanitize(line));
     _count++;
 }
 
+
 DynArray<DynObject>
 DynLoaderSystem::load_Objects()
 {
@@ -189,7 +161,6 @@ DynLoaderSystem::load_Objects()
     }
 
     using BuildFn = int(*)(DynObject*, int);
-    // The cast-through-void* dance silences -Wpedantic on function-pointer casts.
     BuildFn game_build =
         reinterpret_cast<BuildFn>(dlsym(_handle, "game_build"));
     if (!game_build) {
diff --git a/src/systems/dyn_loader_system.hpp b/src/systems/dyn_loader_system.hpp
index 183e4fa..c4adf16 100644
--- a/src/systems/dyn_loader_system.hpp
+++ b/src/systems/dyn_loader_system.hpp
@@ -3,15 +3,12 @@
 #include <string>
 
 #include "systems/game_mod_abi.h"
+#include "text/handler.hpp"
 #include "utils/globals.hpp"
-#include "wall/wall.hpp"
 
 
 class DynLoaderSystem
 {
-    static constexpr float line_height = 0.25f;
-    static constexpr float start_y     = -15.0f;
-
     FILE* _pipe   { nullptr };
     void* _handle { nullptr };
 
@@ -22,15 +19,15 @@ class DynLoaderSystem
     std::string _last_so_path;
 
 
-    void push_Line  (Wall::PtrList& texts, const std::string& line);
+    void push_Line  (Text::Handler& out, const std::string& line);
     static std::string
          wrap_Source(const std::string& user_body);
 
 public:
     bool last_build_ok { false };
 
-    void build       (Wall::PtrList& texts, const std::string& user_src, bool full_source);
-    void update      (Wall::PtrList& texts);
+    void build       (Text::Handler& out, const std::string& user_src, bool full_source);
+    void update      (Text::Handler& out);
 
     DynArray<DynObject>
          load_Objects();
diff --git a/src/systems/hint_system.hpp b/src/systems/hint_system.hpp
index 8876730..c19ece1 100644
--- a/src/systems/hint_system.hpp
+++ b/src/systems/hint_system.hpp
@@ -1,7 +1,7 @@
 #pragma once
 
 #include <string>
-#include "text_3d/text_label.hpp"
+#include "text/handler.hpp"
 #include "entities/camera.hpp"
 #include "utils/light.hpp"
 
@@ -12,7 +12,7 @@ class GlRenderer;
 class HintSystem
 {
     std::string _current_hint;
-    TextLabel   _label;
+    Text::Handler   _label;
 
 public:
     HintSystem   ();
@@ -23,8 +23,8 @@ public:
 
     void set_Hint(const std::string& hint_text);
 
-    TextLabel&
+    Text::Handler&
          get_Label()       { return _label; }
-    const TextLabel&
+    const Text::Handler&
          get_Label() const { return _label; }
 };
diff --git a/src/systems/physics_system.cpp b/src/systems/physics_system.cpp
index 4ceb678..d396f0b 100644
--- a/src/systems/physics_system.cpp
+++ b/src/systems/physics_system.cpp
@@ -6,9 +6,9 @@
 #include "systems/physics_system.hpp"
 #include "systems/projectile_system.hpp"
 #include "text_3d/text_3d.hpp"
-#include "text_3d/text_label.hpp"
 #include "utils/config.hpp"
 #include "utils/frame_profiler.hpp"
+#include "wall/label.hpp"
 
 
 void
@@ -374,7 +374,7 @@ PhysicsSystem::resolve_SingleBoxCollision(
 void
 PhysicsSystem::resolve_Projectiles(DynArray<Projectile>& projectiles,
                                    const DynArray<GameObject*>* statics,
-                                   const DynArray<TextLabel*>*  labels,
+                                   const DynArray<Text::Handler*>*  labels,
                                    DynArray<ProjectileContactEvent>* contacts)
 {
     for (Projectile& proj : projectiles) {
@@ -436,7 +436,7 @@ PhysicsSystem::resolve_Projectiles(DynArray<Projectile>& projectiles,
         if (labels) {
             constexpr double RESTITUTION_LABEL = 0.55;
 
-            for (TextLabel* label : *labels) {
+            for (Text::Handler* label : *labels) {
                 if (!label || label->is_Empty()) continue;
 
                 const auto& lw = label->get_Words();
diff --git a/src/systems/physics_system.hpp b/src/systems/physics_system.hpp
index 9c44789..a5c426c 100644
--- a/src/systems/physics_system.hpp
+++ b/src/systems/physics_system.hpp
@@ -4,11 +4,11 @@
 #include "entities/player.hpp"
 #include "systems/carry_system.hpp"
 #include "systems/collision.hpp"
+#include "text/handler.hpp"
 #include "utils/globals.hpp"
 
 
 struct Projectile;
-class  TextLabel;
 
 
 struct ProjectileContactEvent
@@ -54,7 +54,7 @@ public:
                              const DynArray<Text3dPtr>& words);
     void resolve_Projectiles(DynArray<Projectile>& projectiles,
                              const DynArray<GameObject*>* statics,
-                             const DynArray<TextLabel*>* labels,
+                             const DynArray<Text::Handler*>* labels,
                              DynArray<ProjectileContactEvent>* contacts);
     void resolve_PlayerVsObjects(Player& player,
                                  CarriedLine& carried,
diff --git a/src/systems/pick_system.cpp b/src/systems/pick_system.cpp
index 8663694..761105d 100644
--- a/src/systems/pick_system.cpp
+++ b/src/systems/pick_system.cpp
@@ -58,12 +58,12 @@ ang_score(const GameObject& obj, const Vec3& eye, const Vec3& fwd)
 
 PickResult
 pick(const Camera& camera,
-     const DynArray<Text3dPtr>&     words,
-     const DynArray<GameObjectPtr>& scenery,
-     const DynArray<Wall::Ptr>&   wall_texts)
+     const DynArray<Text3dPtr>&      words,
+     const DynArray<GameObjectPtr>&  scenery,
+     const DynArray<Wall::Ptr>&      wall_texts,
+     const DynArray<Text::Handler*>& walls)
 {
-    const Vec3& eye = camera.pos;
-
+    const Vec3&  eye     = camera.pos;
     const double floor_y = World::get_FloorY();
 
     PickResult result;
@@ -72,8 +72,7 @@ pick(const Camera& camera,
     for (size_t i = 0; i < words.size(); ++i) {
         const Text3dPtr& w = words[i];
         if (!w || !w->interactable) continue;
-        if (w->ai.state != Text3dAI::State::Idle)
-                                    continue;
+        if (w->ai.state != Text3dAI::State::Idle) continue;
         if (w->pos.y < floor_y)     continue;
 
         double a = ang_score(*w, eye, camera.fwd);
@@ -101,12 +100,30 @@ pick(const Camera& camera,
         if (wt->text3d->pos.y < floor_y) continue;
         double a = ang_score(*wt->text3d, eye, camera.fwd);
         if (a >= 0.0 && a < best_angle) {
-            best_angle       = a;
-            result.kind      = PickKind::Wall;
+            best_angle        = a;
+            result.kind       = PickKind::Wall;
             result.wall_index = i;
         }
     }
 
+    for (Text::Handler* h : walls) {
+        if (!h || h->edit_Active()) continue;
+        for (const Text::Line& line : h->get_Lines()) {
+            for (const Text::TokenPtr& tp : line.tokens) {
+                Text::Token* t = tp.get();
+                if (!t || !t->interactable) continue;
+                if (t->pos.y < floor_y)     continue;
+                double a = ang_score(*t, eye, camera.fwd);
+                if (a >= 0.0 && a < best_angle) {
+                    best_angle           = a;
+                    result.kind          = PickKind::WallWord;
+                    result.wall_handler  = h;
+                    result.wall_token    = t;
+                }
+            }
+        }
+    }
+
     return result;
 }
 
diff --git a/src/systems/pick_system.hpp b/src/systems/pick_system.hpp
index fc31e4a..5a94f0c 100644
--- a/src/systems/pick_system.hpp
+++ b/src/systems/pick_system.hpp
@@ -2,6 +2,7 @@
 
 #include "entities/camera.hpp"
 #include "entities/game_object.hpp"
+#include "text/handler.hpp"
 #include "text_3d/text_3d.hpp"
 #include "utils/globals.hpp"
 #include "wall/wall.hpp"
@@ -13,6 +14,7 @@ enum class PickKind : uint8_t
     Scenery,
     Wall,
     Word,
+    WallWord,
 };
 
 
@@ -21,8 +23,10 @@ struct PickResult
     size_t word_index { 0 };
     size_t wall_index { 0 };
 
-    GameObject* scenery_obj { nullptr };
-    PickKind    kind        { PickKind::None };
+    GameObject*    scenery_obj  { nullptr };
+    Text::Handler* wall_handler { nullptr };
+    Text::Token*   wall_token   { nullptr };
+    PickKind       kind         { PickKind::None };
 
     bool hit() const { return kind != PickKind::None; }
 };
@@ -31,7 +35,8 @@ struct PickResult
 namespace PickSystem
 {
     PickResult pick(const Camera& camera,
-                    const DynArray<Text3dPtr>&     words,
-                    const DynArray<GameObjectPtr>& scenery,
-                    const DynArray<Wall::Ptr>&   wall_texts);
+                    const DynArray<Text3dPtr>&        words,
+                    const DynArray<GameObjectPtr>&    scenery,
+                    const DynArray<Wall::Ptr>&        wall_texts,
+                    const DynArray<Text::Handler*>&   walls);
 }
diff --git a/src/systems/program_system.cpp b/src/systems/program_system.cpp
index 09ad3dc..c26b0ff 100644
--- a/src/systems/program_system.cpp
+++ b/src/systems/program_system.cpp
@@ -1,5 +1,4 @@
 #include <SDL3/SDL.h>
-#include <algorithm>
 #include <cerrno>
 #include <fcntl.h>
 #include <signal.h>
@@ -9,7 +8,6 @@
 
 #include "entities/world.hpp"
 #include "systems/program_system.hpp"
-#include "text_3d/text_3d.hpp"
 #include "utils/text.hpp"
 #include "utils/config.hpp"
 
@@ -38,22 +36,16 @@ ProgramSystem::close_Child()
 
 
 void
-ProgramSystem::run(Wall::PtrList& texts, float world_scale)
+ProgramSystem::run(Text::Handler& out)
 {
-    // Clear previous output from the Program wall.
-    texts.erase(
-        std::remove_if(texts.begin(), texts.end(),
-            [](const Wall::Ptr& wt) {
-                return wt->role == Wall::Role::Program;
-            }),
-        texts.end());
+    out.clear();      // wipe previous output from the Program wall
     _count = 0;
 
-    close_Child();   // stop any previous run cleanly
+    close_Child();    // stop any previous run cleanly
 
     // Nothing built yet — tell the player to compile first.
     if (access(kProgramPath, X_OK) != 0) {
-        push_Line(texts, "no program - compile first", world_scale);
+        push_Line(out, "no program - compile first");
         return;
     }
 
@@ -105,7 +97,7 @@ ProgramSystem::run(Wall::PtrList& texts, float world_scale)
 
 
 void
-ProgramSystem::update(Wall::PtrList& texts, float world_scale)
+ProgramSystem::update(Text::Handler& out)
 {
     if (_out_fd < 0) return;
 
@@ -129,16 +121,16 @@ ProgramSystem::update(Wall::PtrList& texts, float world_scale)
                 std::string line = _pending.substr(0, nl);
                 _pending.erase(0, nl + 1);
                 if (!line.empty() && line.back() == '\r') line.pop_back();
-                push_Line(texts, line, world_scale);
+                push_Line(out, line);
             }
         }
         else if (n == 0) {
             // EOF — program finished. Flush any partial line and reap.
             if (!_pending.empty()) {
-                push_Line(texts, _pending, world_scale);
+                push_Line(out, _pending);
                 _pending.clear();
             }
-            if (_count == 0) push_Line(texts, "(no output)", world_scale);
+            if (_count == 0) push_Line(out, "(no output)");
             close_Child();
             return;
         }
@@ -167,66 +159,37 @@ ProgramSystem::feed_Input(const std::string& text)
 
 
 void
-ProgramSystem::echo_Input(Wall::PtrList& texts, const std::string& line)
+ProgramSystem::echo_Input(Text::Handler& out, const std::string& line)
 {
-    push_Line(texts, line, World::get_BoxScale());   // scrolls up, lands at the prompt row
+    push_Line(out, line);   // scrolls up, lands at the prompt row
 }
 
 
 void
-ProgramSystem::echo_Words(Wall::PtrList& texts, DynArray<Text3dPtr>& words, float world_scale)
+ProgramSystem::echo_Words(Text::Handler& out, DynArray<Text3dPtr>& words)
 {
     if (words.empty()) return;
 
-    // Make room: scroll existing program output up by one line.
-    for (Wall::Ptr& wt : texts)
-        if (wt->role == Wall::Role::Program)
-            wt->text3d->pos.y += static_cast<double>(line_height);
-
-    Wall::Transform tf = Wall::get_Transform(Wall::Role::Program, world_scale);
-
-    // The words have already flown to the bottom line; re-home them as Program
-    // wall text (dark, like output) and snap their row onto start_y.
-    for (Text3dPtr& w : words) {
-        if (!w) continue;
-        w->set_Color(ConfigColor::dark_gray);
-        w->yaw   = tf.yaw;
-        w->pos.y = start_y;                 // align the echoed row to the line
-        w->set_Look(ConfigColor::keep, Text3d::depth_wall);
-
-        texts.push_back(cpputils::make_unique<Wall::Text>(Wall::Text{
-            Wall::position_Of(Wall::Role::Program),
-            Wall::Role::Program,
-            std::move(w)
-        }));
+    // The flown word meshes are discarded; we echo their joined text as one
+    // output line (append handles the scroll + colour/placement).
+    std::string line;
+    for (const Text3dPtr& w : words) {
+        if (!w || w->type != ObjectType::Text) continue;
+        if (!line.empty()) line += ' ';
+        line += w->text;
+    }
+    words.clear();
+
+    if (!line.empty()) {
+        out.append_Line(line);
+        _count++;
     }
-    _count++;   // counts as one printed line
 }
 
 
 void
-ProgramSystem::push_Line(Wall::PtrList& texts, const std::string& line, float world_scale)
+ProgramSystem::push_Line(Text::Handler& out, const std::string& line)
 {
-    std::string clean = Text::ascii_Sanitize(line);
-
-    // Shift existing program-wall lines up so new output appears at the bottom.
-    for (Wall::Ptr& wt : texts)
-        if (wt->role == Wall::Role::Program)
-            wt->text3d->pos.y += static_cast<double>(line_height);
-
-    Wall::Transform tf = Wall::get_Transform(Wall::Role::Program, world_scale);
-    Vec3 pos = tf.position;
-    pos.y = start_y;
-
-    // Program wall is light ivory → use a dark text colour for contrast.
-    Text3dPtr t = make_Text3d(clean, Text3d::depth_wall, 0.003f,
-                              pos, ConfigColor::dark_gray);
-    t->yaw = tf.yaw;
-
-    texts.push_back(cpputils::make_unique<Wall::Text>(Wall::Text{
-        tf.wall_position,
-        Wall::Role::Program,
-        std::move(t)
-    }));
+    out.append_Line(Text::ascii_Sanitize(line));
     _count++;
 }
diff --git a/src/systems/program_system.hpp b/src/systems/program_system.hpp
index 8c436d9..30a17ee 100644
--- a/src/systems/program_system.hpp
+++ b/src/systems/program_system.hpp
@@ -4,8 +4,8 @@
 #include <sys/types.h>
 
 #include "entities/world.hpp"
+#include "text/handler.hpp"
 #include "utils/globals.hpp"
-#include "wall/wall.hpp"
 
 
 struct ProgramSystem
@@ -19,18 +19,18 @@ private:
 
     std::string _pending;
 
-    void push_Line  (Wall::PtrList&, const std::string& line, float world_scale);
+    void push_Line  (Text::Handler& out, const std::string& line);
     void close_Child();
 public:
     static inline float line_height = 0.4f;
     static inline float start_y     = World::get_FloorY() + 1;
 
-    void run        (Wall::PtrList&, float world_scale);
-    void update     (Wall::PtrList&, float world_scale);
+    void run        (Text::Handler& out);
+    void update     (Text::Handler& out);
 
     void feed_Input (const std::string& text);
-    void echo_Input (Wall::PtrList&, const std::string& line);
-    void echo_Words (Wall::PtrList&, DynArray<Text3dPtr>& words, float world_scale);
+    void echo_Input (Text::Handler& out, const std::string& line);
+    void echo_Words (Text::Handler& out, DynArray<Text3dPtr>& words);
 
     bool is_Running () const { return _child > 0; }
 
diff --git a/src/systems/pult_system.cpp b/src/systems/pult_system.cpp
index 5b957cc..9185b5e 100644
--- a/src/systems/pult_system.cpp
+++ b/src/systems/pult_system.cpp
@@ -3,7 +3,7 @@
 #include "pult_system.hpp"
 #include "rendering/gl_renderer.hpp"
 #include "systems/physics_system.hpp"
-#include "text_3d/text_label.hpp"
+#include "wall/label.hpp"
 #include "wall/wall.hpp"
 
 
@@ -165,10 +165,10 @@ PultSystem::draw_Labels(GlRenderer& gl, const Camera& camera, const Light& light
 }
 
 
-DynArray<TextLabel*>
+DynArray<Text::Handler*>
 PultSystem::get_Labels() const
 {
-    DynArray<TextLabel*> out;
+    DynArray<Text::Handler*> out;
     out.reserve(_pults.size());
     for (Pult* p : _pults) out.push_back(&p->label);
     return out;
diff --git a/src/systems/pult_system.hpp b/src/systems/pult_system.hpp
index 1d23239..17d299c 100644
--- a/src/systems/pult_system.hpp
+++ b/src/systems/pult_system.hpp
@@ -8,11 +8,12 @@
 #include "utils/globals.hpp"
 
 class  GlRenderer;
-class  TextLabel;
 struct Light;
 struct Player;
 struct ProjectileContactEvent;
 
+namespace Text { class Handler; }
+
 
 class PultSystem
 {
@@ -40,7 +41,7 @@ public:
 
     Vec3 get_ConnectProgramPos   () const;
     Vec3 get_ConnectEditorPos    () const;
-    DynArray<TextLabel*>
+    DynArray<Text::Handler*>
          get_Labels              () const;
 
 
diff --git a/src/systems/wall_connection.cpp b/src/systems/wall_connection.cpp
index 04f7599..f53da0c 100644
--- a/src/systems/wall_connection.cpp
+++ b/src/systems/wall_connection.cpp
@@ -1,43 +1,18 @@
 #include <SDL3/SDL.h>
-#include <sstream>
 
-#include "cpp_token/cpp_highlight.hpp"
-#include "cpp_token/token_color.hpp"
+#include "systems/wall_connection.hpp"
+#include "text/handler.hpp"
+#include "utils/math.hpp"
+
 #include "entities/camera.hpp"
 #include "entities/world.hpp"
 #include "rendering/gl_renderer.hpp"
 #include "systems/program_system.hpp"
-#include "systems/wall_connection.hpp"
 #include "utils/config.hpp"
 #include "utils/light.hpp"
 #include "wall/wall.hpp"
 
 
-static double
-mono_CharWidth()
-{
-    static double w = 0.0;
-    if (w == 0.0) {
-        Text3dPtr m1 = make_Text3d("M",  Text3d::depth_wall, 0.003f, Vec3{0,0,0}, ConfigColor::white);
-        Text3dPtr m2 = make_Text3d("MM", Text3d::depth_wall, 0.003f, Vec3{0,0,0}, ConfigColor::white);
-        const double w1 = 2.0 * static_cast<double>(m1->half_extent);
-        const double w2 = 2.0 * static_cast<double>(m2->half_extent);
-        w = w2 - w1;
-    }
-    return w;
-}
-static double
-mono_HalfExtentY()
-{
-    static double h = 0.0;
-    if (h == 0.0) {
-        Text3dPtr m = make_Text3d("M", Text3d::depth_wall, 0.003f, Vec3{0,0,0}, ConfigColor::white);
-        h = static_cast<double>(m->half_extent_y);
-    }
-    return h;
-}
-
-
 namespace
 Wall
 {
@@ -47,36 +22,28 @@ Connection::begin_Program(const Vec3& pult_pos)
 {
     _target   = Target::Program;
     _pult_pos = pult_pos;
+    _editor   = nullptr;
     _prog_input.reset();
     _prog_last.clear();
 }
 
 void
-Connection::begin_Editor(const Vec3& pult_pos, const DynArray<std::string>& lines, double eye_y)
+Connection::begin_Editor(const Vec3& pult_pos, const ::Text::Handler& editor)
 {
     _target   = Target::Editor;
     _pult_pos = pult_pos;
-    load_Editor(lines, eye_y);
-}
-
-void
-Connection::load_Editor(const DynArray<std::string>& lines, double eye_y)
-{
-    std::vector<std::string> v(lines.begin(), lines.end());
-    _vim.load(v);
-    if (_vim.is_Empty()) _vim.start_Insert();
-    _vim_seen_version = _vim.get_Version();
-    _eye_y = eye_y;
-    _status.clear();
-    rebuild_Editor();
+    _editor   = &editor;           // Handler is edited in place; we only draw chrome
+    _cmd_last.clear();
+    _cmdline.reset();
 }
 
 void
 Connection::end()
 {
     _target = Target::None;
+    _editor = nullptr;
     _prog_input.reset();  _prog_last.clear();
-    _cmdline.   reset();  _status.   clear();
+    _cmdline.   reset();  _cmd_last. clear();
     _blink = 0.0;
 }
 
@@ -108,228 +75,10 @@ Connection::rebuild_ProgramPreview(const std::string& buffer)
 }
 
 
-Connection::Request
-Connection::feed_Vim(DynArray<int>& keys, double eye_y)
-{
-    _eye_y = eye_y;
-
-    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));
-    }
-
-    Request req = Request::None;
-    switch (_vim.take_Signal()) {
-        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");
-            _status.push_back("0 $  line ends   gg G :N  jump line   x dd  delete");
-            _status.push_back(":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();
-        rebuild_Editor();
-    }
-    return req;
-}
-
-
-DynArray<std::string>
-Connection::get_Lines() const
-{
-    DynArray<std::string> out;
-    for (const std::string& l : _vim.get_Lines()) out.push_back(l);
-    return out;
-}
-
-
 void
-Connection::rebuild_Editor()
-{
-    const float ws = World::get_BoxScale();
-    Wall::Transform tf = Wall::get_Transform(Wall::Role::Editor, ws);
-    const double lh    = ConfigWall::line_height;
-    const int    R     = _vim.get_Row();
-    const int    N     = (int)_vim.get_Lines().size();
-    const double bottom_y = -static_cast<double>(ws) + 1.2;
-
-    const double anchor_cursor = _eye_y + static_cast<double>(R) * lh;
-    const double anchor_bottom = bottom_y + static_cast<double>(N - 1) * lh;
-    _row0_y = SDL_min(anchor_cursor, anchor_bottom);
-
-    const auto& lines = _vim.get_Lines();
-
-    const double char_w = mono_CharWidth();
-    const double he_y   = mono_HalfExtentY();
-
-    // Can we reuse cached meshes? Only when the line COUNT is unchanged — then
-    // a line whose text is identical keeps its existing token meshes (we only
-    // reposition). Lines whose text changed are rebuilt. Count change (enter,
-    // dd) → full rebuild (rare vs. per-char typing).
-    const bool can_reuse = (_cached_line_text.size() == lines.size());
-
-    // Move old state aside so we can salvage rows from it.
-    DynArray<EditorRow>  old_rows  = std::move(_rows);
-    DynArray<Text3dPtr>  old_nums  = std::move(_linenums);
-    _rows.clear();
-    _linenums.clear();
-
-    // ── gutter width: build/reuse line-number meshes ──
-    // Numbers only change when the line count changes, so on reuse we keep them.
-    DynArray<Text3dPtr> nums;
-    nums.reserve(lines.size());
-    double gutter_w = 0.0;
-    if (can_reuse && old_nums.size() == lines.size()) {
-        for (size_t i = 0; i < lines.size(); ++i) {
-            gutter_w = SDL_max(gutter_w, 2.0 * static_cast<double>(old_nums[i]->half_extent));
-            nums.push_back(std::move(old_nums[i]));
-        }
-    } else {
-        for (size_t i = 0; i < lines.size(); ++i) {
-            Text3dPtr num = make_Text3d(std::to_string(i + 1), Text3d::depth_wall, 0.0022f,
-                                        tf.position, ConfigColor::orange);
-            num->yaw = tf.yaw;
-            num->set_Color(ConfigColor::orange);
-            gutter_w = SDL_max(gutter_w, 2.0 * static_cast<double>(num->half_extent));
-            nums.push_back(std::move(num));
-        }
-    }
-    const double gutter_gap = 0.6;
-    const double code_u0    = gutter_w + gutter_gap;
-
-    // ── Per-line token meshes + canonical source for lexing. ──
-    struct BuiltTok { Text3dPtr t; size_t col; size_t len; size_t byte_begin; size_t byte_end; };
-    struct BuiltRow { DynArray<BuiltTok> toks; };
-    DynArray<BuiltRow> built;
-    built.reserve(lines.size());
-
-    std::string source;   // canonical buffer for tree-sitter (token-joined)
-
-    double block_code_w = 0.0;
-    for (size_t i = 0; i < lines.size(); ++i) {
-        const std::string& s = lines[i];
-        block_code_w = SDL_max(block_code_w, char_w * static_cast<double>(s.size()));
-
-        BuiltRow br;
-        const bool reuse_line = can_reuse
-                             && i < _cached_line_text.size()
-                             && _cached_line_text[i] == s
-                             && i < old_rows.size();
-
-        // Walk tokens once; build meshes (reuse or fresh) AND record byte ranges
-        // in the canonical source (space-joined tokens, newline-joined lines).
-        size_t pos = 0, ti = 0;
-        bool   first_tok = true;
-        while (pos < s.size()) {
-            while (pos < s.size() && s[pos] == ' ') ++pos;
-            if (pos >= s.size()) break;
-            size_t start = pos; std::string tk;
-            while (pos < s.size() && s[pos] != ' ') { tk += s[pos]; ++pos; }
-
-            if (!first_tok) source += ' ';
-            first_tok = false;
-            const size_t b0 = source.size();
-            source += tk;
-            const size_t b1 = source.size();
-
-            Text3dPtr t;
-            if (reuse_line && ti < old_rows[i].toks.size()) {
-                t = std::move(old_rows[i].toks[ti]);
-            } else {
-                t = make_Text3d(tk, Text3d::depth_wall, 0.003f, tf.position, ConfigColor::white);
-                t->yaw = tf.yaw;
-                // NO TokenColor::apply — coloring comes solely from tree-sitter below.
-            }
-            br.toks.push_back({ std::move(t), start, tk.size(), b0, b1 });
-            ++ti;
-        }
-        source += '\n';
-
-        built.push_back(std::move(br));
-    }
-
-    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;
-
-    // ── Place numbers + tokens (always reposition — cheap). ──
-    for (size_t i = 0; i < lines.size(); ++i) {
-        const double row_y = _row0_y - static_cast<double>(i) * lh;
-
-        if (nums[i]) {
-            const double he = static_cast<double>(nums[i]->half_extent);
-            nums[i]->pos   = tf.position;
-            nums[i]->pos.y = row_y;
-            nums[i]->pos.z = z_block_left - he;
-            _linenums.push_back(std::move(nums[i]));
-        }
-
-        EditorRow er;
-        er.left_z   = _code_left_z;
-        er.char_w   = char_w;
-        er.he_y     = he_y;
-        er.line_x   = tf.position.x;
-        er.has_text = !built[i].toks.empty();
-
-        for (BuiltTok& 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;
-            er.toks.push_back(std::move(bt.t));
-        }
-
-        _rows.push_back(std::move(er));
-    }
-
-    // Snapshot line text for next reuse check.
-    _cached_line_text.clear();
-    for (const std::string& s : lines) _cached_line_text.push_back(s);
-
-    // ── Color every token via tree-sitter (same classifier as the wall). ──
-    {
-        DynArray<HiSpan> spans = CppHighlight::classify(source);
-        // For each span, color the tokens whose byte range overlaps it.
-        // built[] holds byte ranges; _rows[] holds the meshes in the same order.
-        for (const HiSpan& span : spans) {
-            for (size_t i = 0; i < built.size() && i < _rows.size(); ++i) {
-                for (size_t j = 0; j < built[i].toks.size() && j < _rows[i].toks.size(); ++j) {
-                    const BuiltTok& bt = built[i].toks[j];
-                    // overlap test: [bt.byte_begin,bt.byte_end) ∩ [span.begin,span.end)
-                    if (bt.byte_begin < span.byte_end && span.byte_begin < bt.byte_end) {
-                        if (_rows[i].toks[j])
-                            _rows[i].toks[j]->set_Color(TokenColor::get(span.type));
-                    }
-                }
-            }
-        }
-    }
-
-    rebuild_Cmdline();
-}
-
-
-void
-Connection::rebuild_Cmdline()
+Connection::rebuild_Cmdline(const std::string& text)
 {
     _cmdline.reset();
-
-    std::string text;
-    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() == VimBufferOLD::Mode::Insert) ? "-- INSERT --" : "-- NORMAL --";
     if (text.empty()) return;
 
     // Built at origin; render() places it at the eased _cmd_pos each frame.
@@ -343,15 +92,20 @@ Connection::update(double dt, const Camera& cam, float aspect, float fov_y)
     if (!is_Active()) return;
     _blink += dt;
 
+    // Editor mode-line text comes from the Handler's vim state — rebuild the
+    // floating mesh only when it changes.
+    if (_target == Target::Editor && _editor) {
+        std::string ml = _editor->modeline();
+        if (ml != _cmd_last) { rebuild_Cmdline(ml); _cmd_last = ml; }
+    }
+
     const Vec3 up = Math::cross(cam.right, cam.fwd);
 
     const double dist   = 2.2;
     const double half_h = dist * static_cast<double>(SDL_tanf(fov_y * 0.5f));
     const double half_w = half_h * static_cast<double>(aspect);
 
-    // Sit at a fraction of the way toward the lower-left corner. 0 = center,
-    // 1 = exactly the frustum edge (text would be clipped at the very corner),
-    // so keep a margin.
+    // Sit a fraction of the way toward the lower-left frustum corner.
     const double fx = 0.82;   // horizontal: 82% toward the left edge
     const double fy = 0.78;   // vertical:   78% toward the bottom edge
     const double left = half_w * fx;
@@ -384,11 +138,8 @@ Connection::render(GlRenderer& gl, const Camera& cam, const Light& light) const
         draw_Electric     (gl, cam);
     }
     else if (_target == Target::Editor) {
-        for (const EditorRow& er : _rows)
-            for (const Text3dPtr& t : er.toks)
-                if (t) t->draw(gl, cam, light);
-        for (const Text3dPtr& n : _linenums) if (n) n->draw(gl, cam, light);
-
+        // Tokens + line numbers are drawn by the Handler itself; we only add the
+        // editing chrome on top.
         if (_cmdline) {
             const double hw = static_cast<double>(_cmdline->half_extent);
             _cmdline->pos = Vec3{
@@ -436,21 +187,20 @@ Connection::draw_ProgramCursor(GlRenderer& gl, const Camera& cam, const Light& l
 void
 Connection::draw_EditorCursor(GlRenderer& gl, const Camera& cam, const Light& light) const
 {
+    if (!_editor) return;
     if (SDL_fmod(_blink, 0.8) > 0.4) return;
 
-    const int row = _vim.get_Row();
-    if (row < 0 || row >= (int)_rows.size()) return;
+    const ::Text::Handler::MonoMetrics m = _editor->mono_Metrics();
+    if (m.char_w <= 0.0) return;                 // no mono layout yet
 
-    Wall::Transform tf = Wall::get_Transform(Wall::Role::Editor, World::get_BoxScale());
-    const double lh    = ConfigWall::line_height;
-    const double row_y = _row0_y - static_cast<double>(row) * lh;
+    const int    row   = _editor->cursor_Row();
+    const int    col   = _editor->cursor_Col();
+    const double row_y = _editor->row_Y(row);
+    const double cz    = m.left_z - (static_cast<double>(col) + 0.5) * m.char_w;
 
-    const EditorRow& er = _rows[(size_t)row];
-    const double cw     = er.char_w;
-    const int    col    = _vim.get_Col();
-    const double cz     = er.left_z - (static_cast<double>(col) + 0.5) * cw;
+    Wall::Transform tf = Wall::get_Transform(Wall::Role::Editor, World::get_BoxScale());
 
-    Vec3 c { er.line_x, row_y - (er.he_y + 0.06), cz };
+    Vec3 c { tf.position.x, row_y - (m.he_y + 0.06), cz };
 
     Mat4 view      = buildViewMat4 (cam);
     Mat4 get_Model = buildModelMat4(c, tf.yaw);
@@ -462,7 +212,7 @@ Connection::draw_EditorCursor(GlRenderer& gl, const Camera& cam, const Light& li
     gl.set_Light    (light);
     gl.set_Color    (0.6f, 1.0f, 1.0f);
     gl.set_Emissive (0.6f, 1.0f, 1.0f, 0.9f);
-    gl.draw_Box     (static_cast<float>(cw * 0.45), 0.015f, 0.03f);
+    gl.draw_Box     (static_cast<float>(m.char_w * 0.45), 0.015f, 0.03f);
     gl.set_Emissive (0.0f, 0.0f, 0.0f, 0.0f);
 }
 
diff --git a/src/systems/wall_connection.hpp b/src/systems/wall_connection.hpp
index 474fea4..44fafbe 100644
--- a/src/systems/wall_connection.hpp
+++ b/src/systems/wall_connection.hpp
@@ -1,72 +1,50 @@
 #pragma once
 
 #include <string>
-#include <vector>
 
 #include "text_3d/text_3d.hpp"
-#include "text_3d/vim_buffer.hpp"
-#include "utils/globals.hpp"
 
 
 class Camera;
 class GlRenderer;
 struct Light;
+namespace Text { class Handler; }
 
 
-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
-    bool   has_text { false };
-};
-
 namespace Wall
 {
 
+// Pure overlay: draws the editing chrome (cmdline / cursor / electric arc) on
+// top of whatever owns the actual text. For the editor it reads cursor + layout
+// state from a Text::Handler that is being vim-edited in place (Mono layout);
+// the Handler draws its own tokens. For the program it owns a small stdin echo.
 class Connection
 {
 public:
-    enum class Target  : uint8_t { None, Program, Editor };
-    enum class Request : uint8_t { None, Exit, Write, WriteQuit, QuitDiscard };
+    enum class Target : uint8_t { None, Program, Editor };
 
     void    update(double dt, const Camera& cam, float aspect, float fov_y);
     void    render(GlRenderer& gl, const Camera& cam, const Light& light) const;
     void    end   ();
 
     bool    is_Active () const { return _target != Target::None; }
-
     Target  get_Target() const { return _target; }
-    DynArray<std::string>
-            get_Lines () const;
 
     void    set_ProgramBuffer   (const std::string& buffer);
-
-    void    begin_Program(const Vec3& pult_pos);
-    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    clear_ProgramPreview();
-    Request feed_Vim            (DynArray<int>& keys, double eye_y);
 
+    void    begin_Program(const Vec3& pult_pos);
+    void    begin_Editor (const Vec3& pult_pos, const ::Text::Handler& editor);
 
 
 private:
-    bool     _cmd_pos_init     { false };
-    unsigned _vim_seen_version { 0 };
+    bool   _cmd_pos_init { false };
+    float  _cmd_yaw      { 0.0f };
+    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 };
 
-    Target _target{ Target::None };
+    const ::Text::Handler* _editor { nullptr };   // editor model (Editor target only)
 
     Text3dPtr _cmdline;
     Text3dPtr _prog_input;
@@ -74,17 +52,10 @@ private:
     Vec3 _cmd_pos {};
     Vec3 _pult_pos{};
 
+    std::string _cmd_last;
     std::string _prog_last;
 
-    VimBufferOLD _vim;
-
-    DynArray<std::string> _cached_line_text;
-    DynArray<EditorRow>   _rows;
-    DynArray<Text3dPtr>   _linenums;
-    DynArray<std::string> _status;
-
-    void rebuild_Editor ();
-    void rebuild_Cmdline();
+    void rebuild_Cmdline       (const std::string& text);
     void rebuild_ProgramPreview(const std::string& buffer);
     void draw_EditorCursor (GlRenderer& gl, const Camera& cam, const Light& light) const;
     void draw_ProgramCursor(GlRenderer& gl, const Camera& cam, const Light& light) const;
diff --git a/src/text/handler.cpp b/src/text/handler.cpp
index f04120d..33481b1 100644
--- a/src/text/handler.cpp
+++ b/src/text/handler.cpp
@@ -1,15 +1,3 @@
-// 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>
@@ -79,6 +67,7 @@ Handler::Handler(Wall::Role role, float world_scale, LayoutMode mode)
     _tf           = Wall::get_Transform(role, world_scale);
     _line_height  = ConfigWall::line_height;
     _word_spacing = ConfigWall::free_space_dist;
+    _append_color = ConfigColor::dark_gray;
 }
 
 void
@@ -113,6 +102,8 @@ Handler::clear()
 {
     _lines.clear();
     _last_src.clear();
+    _label_off.clear();
+    _label_text.clear();
     mark_Dirty();
 }
 
@@ -154,11 +145,11 @@ Handler::append_Line(const std::string& text)
     Line ln;
     ln.y = _append_y;
 
-    TokenPtr t = make_Token(text, _depth, _scale, _tf.position, ConfigColor::dark_gray);
+    TokenPtr t = make_Token(text, _depth, _scale, _tf.position, _append_color);
     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
+    t->set_Color(_append_color);   // Static; program output isn't lexed
     ln.tokens.push_back(std::move(t));
 
     _lines.push_back(std::move(ln));
@@ -310,8 +301,16 @@ Handler::apply_Target(const Target& t)
     Line fresh;
     fresh.y           = t.new_y;
     fresh.line_height = _line_height;
-    _lines.push_back(std::move(fresh));
-    return _lines.back();
+
+    // Keep _lines ordered top->bottom (descending y). as_Lines / render_Source /
+    // numbering all read array order, so the array order IS the document order;
+    // a line shot in above existing text must land at the matching index, not
+    // at the end (that's what made the extracted source come out reordered).
+    size_t pos = 0;
+    while (pos < _lines.size() && _lines[pos].y > fresh.y) ++pos;
+    auto it = _lines.insert(_lines.begin() + static_cast<std::ptrdiff_t>(pos),
+                            std::move(fresh));
+    return *it;
 }
 
 void
@@ -545,8 +544,9 @@ Handler::as_Lines() const
 void
 Handler::relayout()
 {
-    if (_layout == LayoutMode::Mono) relayout_Mono();
-    else                             relayout_Snapped();
+    if      (_layout == LayoutMode::Mono)      relayout_Mono();
+    else if (_layout == LayoutMode::Billboard) relayout_Billboard();
+    else                                       relayout_Snapped();
 }
 
 void
@@ -812,6 +812,7 @@ Handler::apply_Tilt(const Camera& cam)
 void
 Handler::update(double dt, const Camera& cam)
 {
+    if (_layout == LayoutMode::Billboard) { billboard_Update(dt, cam); return; }
     if (_layout == LayoutMode::Snapped && _show_numbers)
         maintain_Numbers(dt, cam);
     apply_Tilt(cam);
@@ -996,4 +997,227 @@ Handler::cmdline_Text() const
 const DynArray<std::string>&
 Handler::status_Lines() const { return _status; }
 
+std::string
+Handler::modeline() const
+{
+    if (!_vim) return {};
+    if (_vim->get_Mode() == VimBuffer::Mode::Command) return _vim->get_Cmdline();
+    if (!_status.empty())                             return _status[0];
+    if (!_vim->get_Status().empty())                  return _vim->get_Status();
+    return (_vim->get_Mode() == VimBuffer::Mode::Insert) ? "-- INSERT --"
+                                                         : "-- NORMAL --";
+}
+
+
+// ───────────────────────────── billboard label ─────────────────────────────
+// Port of TextLabel: one centred line that faces the eye (or a fixed yaw) and
+// jiggles on kicks. The token meshes are built once (relayout_Billboard); the
+// per-frame anchor follow + spring runs in billboard_Update.
+
+void
+Handler::set_Style(SDL_FColor color, float depth, float scale)
+{
+    _style_color = color;
+    _depth       = depth;
+    _scale       = scale;
+}
+
+void
+Handler::set_Centre(const Vec3& p)
+{
+    _layout     = LayoutMode::Billboard;
+    _pos_mode   = PositionMode::Absolute;
+    _centre     = p;
+    _anchor_ptr = nullptr;
+}
+
+void
+Handler::attach_To(const Vec3* anchor, const Vec3& offset)
+{
+    _layout        = LayoutMode::Billboard;
+    _pos_mode      = PositionMode::Anchor;
+    _anchor_ptr    = anchor;
+    _attach_offset = offset;
+}
+
+void
+Handler::detach()
+{
+    _pos_mode   = PositionMode::Absolute;
+    _anchor_ptr = nullptr;
+}
+
+void
+Handler::set_Text(const std::string& text)
+{
+    if (text == _label_text && !_lines.empty()) return;
+    _label_text = text;
+    _layout     = LayoutMode::Billboard;
+    relayout_Billboard();
+}
+
+void
+Handler::set_Text(const std::string& text, SDL_FColor color, float depth, float scale)
+{
+    set_Style(color, depth, scale);
+    _label_text.clear();
+    set_Text(text);
+}
+
+void
+Handler::set_Collidable(bool on)
+{
+    _collidable = on;
+    if (_lines.empty()) return;
+    for (TokenPtr& w : _lines[0].tokens) {
+        if (!w) continue;
+        w->solid        = on;
+        w->carriable    = false;
+        w->interactable = false;
+        w->body.mode    = on ? PhysicsMode::Static : PhysicsMode::Hover;
+    }
+}
+
+const DynArray<TokenPtr>&
+Handler::get_Words() const
+{
+    static const DynArray<TokenPtr> empty;
+    return _lines.empty() ? empty : _lines[0].tokens;
+}
+
+Vec3
+Handler::label_Centre() const
+{
+    if (_pos_mode == PositionMode::Anchor && _anchor_ptr)
+        return { _anchor_ptr->x + _attach_offset.x,
+                 _anchor_ptr->y + _attach_offset.y,
+                 _anchor_ptr->z + _attach_offset.z };
+    return _centre;
+}
+
+void
+Handler::relayout_Billboard()
+{
+    _lines.clear();
+    _label_off.clear();
+
+    DynArray<std::string> toks;
+    {
+        std::istringstream ss(_label_text);
+        std::string w;
+        while (ss >> w) toks.push_back(w);
+    }
+    if (toks.empty()) return;
+
+    DynArray<TokenPtr> built;
+    built.reserve(toks.size());
+    for (const std::string& tk : toks)
+        built.push_back(make_Token(tk, _depth, _scale, Vec3{0,0,0}, _style_color));
+
+    float total_w = 0.0f;
+    for (const TokenPtr& t : built) total_w += t->half_extent * 2.0f + ConfigText3d::word_gap;
+    total_w -= ConfigText3d::word_gap;
+
+    Line line;
+    float x_cur = -total_w * 0.5f;
+    for (TokenPtr& t : built) {
+        const float he = t->half_extent;
+        _label_off.push_back(static_cast<double>(x_cur + he));
+
+        t->carriable        = false;
+        t->interactable     = false;
+        t->solid            = _collidable;
+        t->body.mode        = _collidable ? PhysicsMode::Static : PhysicsMode::Hover;
+        t->body.restitution = 0.55;
+        t->jiggle_off       = Vec3{};
+        t->jiggle_vel       = Vec3{};
+
+        line.tokens.push_back(std::move(t));
+        x_cur += he * 2.0f + ConfigText3d::word_gap;
+    }
+
+    _lines.push_back(std::move(line));
+}
+
+void
+Handler::billboard_Update(double dt, const Camera& cam)
+{
+    if (_lines.empty()) return;
+    DynArray<TokenPtr>& words = _lines[0].tokens;
+
+    const Vec3 centre = label_Centre();
+    Vec3  right   { 1, 0, 0 };
+    float yaw_g   { 0.0f };
+    float pitch_g { 0.0f };
+
+    if (_billboard) {
+        Vec3   fwd  = cam.pos - centre;
+        double flen = Math::length(fwd);
+        if (flen < 0.0001) return;
+        fwd = fwd / flen;
+
+        right = Math::cross(Vec3{0, 1, 0}, fwd);
+        double rlen = Math::length(right);
+        if (rlen < 0.0001) return;
+        right = right / rlen;
+
+        yaw_g   =  SDL_atan2f(static_cast<float>(fwd.x), static_cast<float>(fwd.z));
+        pitch_g = -SDL_asinf (static_cast<float>(fwd.y));
+    } else {
+        yaw_g   = _face_yaw;
+        pitch_g = 0.0f;
+        right   = { SDL_cos(_face_yaw), 0.0, -SDL_sin(_face_yaw) };
+    }
+
+    auto clamp = [](double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); };
+
+    for (size_t i = 0; i < words.size() && i < _label_off.size(); ++i) {
+        Token* t = words[i].get();
+        if (!t || !t->loaded) continue;
+
+        const double off = _label_off[i];
+        const Vec3 anchor {
+            centre.x + right.x * off,
+            centre.y + right.y * off,
+            centre.z + right.z * off,
+        };
+
+        Vec3& o = t->jiggle_off;
+        Vec3& v = t->jiggle_vel;
+        const Vec3 acc {
+            -ConfigText3d::jiggle_stiff * o.x - ConfigText3d::jiggle_damp * v.x,
+            -ConfigText3d::jiggle_stiff * o.y - ConfigText3d::jiggle_damp * v.y,
+            -ConfigText3d::jiggle_stiff * o.z - ConfigText3d::jiggle_damp * v.z,
+        };
+        v.x += acc.x * dt; v.y += acc.y * dt; v.z += acc.z * dt;
+        o.x += v.x   * dt; o.y += v.y   * dt; o.z += v.z   * dt;
+        o.x = clamp(o.x, -ConfigText3d::jiggle_max_off, ConfigText3d::jiggle_max_off);
+        o.y = clamp(o.y, -ConfigText3d::jiggle_max_off, ConfigText3d::jiggle_max_off);
+        o.z = clamp(o.z, -ConfigText3d::jiggle_max_off, ConfigText3d::jiggle_max_off);
+
+        t->pos   = { anchor.x + o.x, anchor.y + o.y, anchor.z + o.z };
+        t->yaw   = yaw_g;
+        t->pitch = pitch_g;
+    }
+}
+
+void
+Handler::kick(size_t word_index, const Vec3& projectile_vel, const Vec3& normal)
+{
+    if (_lines.empty()) return;
+    DynArray<TokenPtr>& words = _lines[0].tokens;
+    if (word_index >= words.size() || !words[word_index]) return;
+
+    double v_into = -(projectile_vel.x * normal.x +
+                      projectile_vel.y * normal.y +
+                      projectile_vel.z * normal.z);
+    if (v_into < 0.0) v_into = 0.0;
+    const double mag = v_into * ConfigText3d::kick_strength;
+
+    Vec3& v = words[word_index]->jiggle_vel;
+    v.x += -normal.x * mag;
+    v.y += -normal.y * mag;
+    v.z += -normal.z * mag;
+}
+
 } // namespace Text
diff --git a/src/text/handler.hpp b/src/text/handler.hpp
index 8736ca2..d52fb21 100644
--- a/src/text/handler.hpp
+++ b/src/text/handler.hpp
@@ -27,9 +27,9 @@
 #include "entities/camera.hpp"
 #include "text/line.hpp"
 #include "text/token.hpp"
+#include "text/vim_buffer.hpp"        // VimBuffer (owned in Mono mode)
 #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;
@@ -37,7 +37,7 @@ class GlRenderer;
 namespace Text
 {
 
-enum class LayoutMode : uint8_t { Snapped, Mono };
+enum class LayoutMode : uint8_t { Snapped, Mono, Billboard };
 
 class Handler
 {
@@ -79,6 +79,7 @@ public:
     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)
+    void set_AppendColor   (SDL_FColor c) { _append_color = c; }  // output text color for append_Line
 
     // ── ownership transfer (the heart of the rewrite) ─────────────────────
     // detach: remove a token from its line, hand ownership out (pick / shoot-off).
@@ -147,6 +148,26 @@ public:
     VimBuffer::Mode              cursor_Mode    () const;
     const std::string&           cmdline_Text   () const;
     const DynArray<std::string>& status_Lines   () const;
+    std::string                  modeline       () const;   // mode/cmdline/status text for the overlay
+
+    // ── billboard label (LayoutMode::Billboard) ───────────────────────────
+    // One centred line of words that faces the eye (or a fixed yaw) and jiggles
+    // on projectile kicks. Mirrors the old TextLabel API verbatim so call sites
+    // swap a TextLabel for a Handler with no other change. detach() (no args) is
+    // the anchor-detach; detach(Token*) above is the ownership-transfer one.
+    void set_Text      (const std::string& text);
+    void set_Text      (const std::string& text, SDL_FColor color, float depth, float scale);
+    void set_Centre    (const Vec3& p);
+    void attach_To     (const Vec3* anchor, const Vec3& offset = {});
+    void detach        ();
+    void set_Billboard (bool on) { _billboard = on; }
+    void set_FaceYaw   (float y) { _face_yaw  = y;  }
+    void set_Style     (SDL_FColor color, float depth, float scale);
+    void set_Collidable(bool on);
+    void kick          (size_t word_index, const Vec3& projectile_vel, const Vec3& normal);
+    bool is_Empty      () const { return _lines.empty() || _lines[0].tokens.empty(); }
+    const DynArray<TokenPtr>& get_Words() const;
+    void draw          (GlRenderer& gl, const Camera& cam, const Light& light) const { render(gl, cam, light); }
 
 private:
     DynArray<Line> _lines;                 // single source of truth
@@ -162,6 +183,7 @@ private:
     double _line_height  { 0.0 };
     double _word_spacing { 0.0 };
     double _append_y     { 0.0 };   // program-wall bottom row
+    SDL_FColor _append_color { 0.45f, 0.45f, 0.45f, 1.0f };  // append_Line text color
 
     DynArray<std::string> _status;  // multi-line status (vim :help etc.), for the overlay
 
@@ -187,6 +209,23 @@ private:
     cpputils::unique_ptr<VimBuffer> _vim;
     unsigned                        _vim_seen_version { 0 };
 
+    // ── billboard-label state (LayoutMode::Billboard) ─────────────────────
+    enum class PositionMode : uint8_t { Absolute, Anchor };
+    PositionMode _pos_mode    { PositionMode::Absolute };
+    const Vec3*  _anchor_ptr  { nullptr };
+    Vec3         _centre        {};
+    Vec3         _attach_offset {};
+    SDL_FColor   _style_color { 1.0f, 1.0f, 0.0f, 1.0f };
+    std::string  _label_text;
+    DynArray<double> _label_off;          // per-word centre offset along the row
+    bool         _billboard   { true };
+    bool         _collidable  { true };
+    float        _face_yaw    { 0.0f };
+
+    void   relayout_Billboard();           // (re)build the single centred line
+    void   billboard_Update  (double dt, const Camera& cam);
+    Vec3   label_Centre      () const;
+
     // layout
     void   relayout       ();              // dispatch on _layout
     void   relayout_Snapped();
diff --git a/src/text/text.cpp b/src/text/text.cpp
new file mode 100644
index 0000000..e69de29
diff --git a/src/text/text.hpp b/src/text/text.hpp
new file mode 100644
index 0000000..e69de29
diff --git a/src/text/token.hpp b/src/text/token.hpp
index 816b9aa..d45401c 100644
--- a/src/text/token.hpp
+++ b/src/text/token.hpp
@@ -111,6 +111,11 @@ struct Token final : public GameObject
     size_t byte_begin { 0 };
     size_t byte_end   { 0 };
 
+    // ── billboard-label jiggle (only used in LayoutMode::Billboard; was
+    //    Text::Handler's parallel _jiggle_off/_jiggle_vel arrays) ──
+    Vec3 jiggle_off {};
+    Vec3 jiggle_vel {};
+
 
     // ── AI state (by-value; idle on document tokens, animated only on free
     //    words by WordAiSystem — matches the old Text3d::ai member exactly) ──
diff --git a/src/text_3d/text_3d.cpp b/src/text_3d/text_3d.cpp
deleted file mode 100644
index 084e16d..0000000
--- a/src/text_3d/text_3d.cpp
+++ /dev/null
@@ -1,351 +0,0 @@
-#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"
-#include "text_3d.hpp"
-#include "utils/frame_profiler.hpp"
-#include "utils/globals.hpp"
-
-
-static SDL_FColor
-aiGlintColor(Text3dAI::State s)
-{
-    switch (s) {
-        case Text3dAI::State::Attack:  return {1.0f, 0.85f, 0.75f, 1.0f};
-        case Text3dAI::State::Flee:    return {1.0f, 1.0f,  0.80f, 1.0f};
-        case Text3dAI::State::Stunned: return {0.85f, 0.85f, 0.95f, 1.0f};
-        case Text3dAI::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 };
-}
-
-
-Text3d::Text3d(const std::string& txt,
-               float dpth, float scle,
-               Vec3 position, SDL_FColor colr)
-       : GameObject(position, ObjectType::Text,
-                    CollisionShape{}, RigidBody{PhysicsMode::Hover},
-                    true, true, true),
-         depth(dpth), scale(scle), color(colr), text(txt)
-{
-    set_Color(TokenColor::get(CppHighlight::classify_Word(text)));
-
-
-    rebuild_Mesh();
-    body.hover_phase = Random::unit() * SDL_PI_D * 2.0;
-
-
-    // AI personality
-    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;
-}
-
-
-Text3d::~Text3d()
-{
-    // 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);
-}
-
-
-Vec3
-Text3d::words_centroid(const DynArray<Text3dPtr>& words)
-{
-    Vec3   c{};
-    double n = 0.0;
-    for (const Text3dPtr& w : words) {
-        if (!w) continue;
-        c += w->pos;
-        n += 1.0;
-    }
-    if (n > 0.0) c = c / n;
-    return c;
-}
-
-
-Vec3
-Text3d::get_Distance(Vec3 position) const {
-    return pos - position;
-}
-
-
-void
-Text3d::set_Color(SDL_FColor c)
-{
-    color = c;
-}
-
-
-void
-Text3d::set_Depth(float new_depth)
-{
-    if (new_depth == depth) return;
-    depth = new_depth;
-    rebuild_Mesh();
-}
-
-
-void
-Text3d::set_Look(SDL_FColor c, float new_depth)
-{
-    if (c.r == -1.0f)
-        color = TokenColor::get(CppHighlight::classify_Word(text));
-    else if (c.g == -1.0f) { }
-    else color = c;
-
-    if (new_depth == depth) return;
-    depth = new_depth;
-    rebuild_Mesh();
-}
-
-
-void
-Text3d::rebuild_Mesh()
-{
-    loaded = mesh.build(font_path, text, 64, depth, scale);
-    if (!loaded) {
-        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Text3d: 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 GPU-ready interleaved buffer once. draw() just uploads/binds this.
-    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
-Text3d::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);
-}
-
-
-void
-Text3d::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 ──────────────────────────────────────────────────
-    if (ai.state != Text3dAI::State::Idle) {
-        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);
-    }
-}
diff --git a/src/text_3d/text_3d.hpp b/src/text_3d/text_3d.hpp
index 0ddb0ae..0890f82 100644
--- a/src/text_3d/text_3d.hpp
+++ b/src/text_3d/text_3d.hpp
@@ -1,116 +1,25 @@
 #pragma once
+//
+// src/text_3d/text_3d.hpp  —  forwarding shim
+//
+// The old `Text3d` is now `Text::Token` (in text/token.hpp). The pointer-level
+// aliases `Text3d` / `Text3dPtr` live in utils/globals.hpp (forward-decl-safe).
+// This header pulls in the full Token definition and adds the aliases that need
+// the complete type: `Text3dAI` and `make_Text3d`.
+//
+// The old src/text_3d/text_3d.cpp is superseded by src/text/token.cpp and MUST
+// be removed from the build (otherwise duplicate Token:: definitions at link).
 
-#include <SDL3/SDL.h>
-#include <string>
+#include <utility>
 
-#include "cpp_token/token_db.hpp"
-#include "cpputils/unique_ptr.hpp"
-#include "entities/game_object.hpp"
-#include "text_3d/text_mesh.hpp"
-#include "utils/config.hpp"
-#include "utils/globals.hpp"
-#include "utils/light.hpp"
-
-
-class Camera;
-class GlRenderer;
-
-
-struct Text3dAI
-{
-    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<Text3dPtr> stolen;
-    Vec3 trail_dir {1.0, 0.0, 0.0};
-};
-
-
-struct Text3d final : public GameObject
-{
-    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;
-    static Vec3 words_centroid(const DynArray<Text3dPtr>& words);
-
-    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];
-
-
-    bool  glow_enabled { false };
-    bool  loaded       { false };
-    bool  front_tint   { true };
-
-    float glow_strength{ 0.2f };
-    float half_extent_y{ 0.2f };
-    float back_dim     { 0.45f };
-    float depth;
-    float scale;
-    float half_extent  { 0.0f };
-    float pitch        { 0.0f };
-
-    Vec3  ribbon_local {};
-
-    SDL_FColor  color;
-    std::string text;
-    TextMesh    mesh;
-    Text3dAI    ai;
-
-    DynArray<int>   packed_indices;
-    DynArray<float> packed_verts;
-
-    Text3d(const std::string& txt,
-           float depth, float scale,
-           Vec3  position,
-           SDL_FColor colr = ConfigColor::blue);
-    ~Text3d() override;
-
-    Vec3 get_Distance(Vec3 position) const;
-    void set_Color   (SDL_FColor c);
-    void set_Depth   (float depth);
-    void set_Look    (SDL_FColor c, float depth);
-    void rebuild_Mesh();
-    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;
-};
+#include "cpp_token/token_db.hpp"   // parity with the old header's transitive include
+#include "text/token.hpp"           // brings the full Text::Token (and globals' Text3d/Text3dPtr)
 
+using Text3dAI = Text::Token::Ai;
 
 template<typename... Args>
-cpputils::unique_ptr<Text3d>
+Text3dPtr
 make_Text3d(Args&&... args)
 {
-    return cpputils::make_unique<Text3d>(std::forward<Args>(args)...);
+    return Text::make_Token(std::forward<Args>(args)...);
 }
diff --git a/src/text_3d/text_label.cpp b/src/text_3d/text_label.cpp
deleted file mode 100644
index 4e2b2eb..0000000
--- a/src/text_3d/text_label.cpp
+++ /dev/null
@@ -1,260 +0,0 @@
-#include <algorithm>
-#include <sstream>
-
-#include "rendering/gl_renderer.hpp"
-#include "text_3d/text_label.hpp"
-
-static DynArray<std::string>
-split_words(const std::string& text)
-{
-    DynArray<std::string> out;
-    std::istringstream ss(text);
-    std::string word;
-    while (ss >> word)
-        out.push_back(word);
-    return out;
-}
-
-
-void
-TextLabel::set_Text(const std::string& text)
-{
-    if (text == _current_text) return;
-    _current_text = text;
-    rebuild(text);
-}
-
-
-void
-TextLabel::set_Text(const std::string& text,
-                    SDL_FColor color, float depth, float scale)
-{
-    set_Style(color, depth, scale);
-    _current_text.clear();
-    set_Text(text);
-}
-
-
-void
-TextLabel::clear()
-{
-    _words       .clear();
-    _offsets     .clear();
-    _anchor_pos  .clear();
-    _jiggle_off  .clear();
-    _jiggle_vel  .clear();
-    _current_text.clear();
-}
-
-
-void
-TextLabel::set_Centre(const Vec3& p)
-{
-    _mode       = PositionMode::Absolute;
-    _centre     = p;
-    _anchor_ptr = nullptr;
-}
-
-
-void
-TextLabel::attach_To(const Vec3* anchor, const Vec3& offset)
-{
-    _mode          = PositionMode::Anchor;
-    _anchor_ptr    = anchor;
-    _attach_offset = offset;
-}
-
-
-void
-TextLabel::detach()
-{
-    _mode       = PositionMode::Absolute;
-    _anchor_ptr = nullptr;
-}
-
-void
-TextLabel::set_Style(SDL_FColor color, float depth, float scale)
-{
-    _style_color = color;
-    _style_depth = depth;
-    _style_scale = scale;
-}
-
-
-void
-TextLabel::set_Collidable(bool on)
-{
-    _collidable = on;
-    for (Text3dPtr& w : _words) {
-        if (!w) continue;
-        w->solid        = on;
-        w->carriable    = false;
-        w->interactable = false;
-        w->body.mode    = on ? PhysicsMode::Static : PhysicsMode::Hover;
-    }
-}
-
-
-void
-TextLabel::rebuild(const std::string& text)
-{
-    _words.clear();
-    _offsets.clear();
-    _anchor_pos.clear();
-    _jiggle_off.clear();
-    _jiggle_vel.clear();
-
-    DynArray<std::string> tokens = split_words(text);
-    if (tokens.empty()) return;
-
-    struct Tmp { Text3dPtr obj; };
-    DynArray<Tmp> tmp;
-    tmp.reserve(tokens.size());
-    for (const std::string& tok : tokens) {
-        tmp.push_back({ make_Text3d(tok, _style_depth, _style_scale,
-                                    Vec3{0,0,0}, _style_color) });
-    }
-
-    float total_w = 0.0f;
-    for (auto& t : tmp) total_w += t.obj->half_extent * 2.0f + ConfigText3d::word_gap;
-    total_w -= ConfigText3d::word_gap;
-
-    float x_cur = -total_w * 0.5f;
-    for (auto& t : tmp) {
-        float he = t.obj->half_extent;
-        _offsets.push_back(static_cast<double>(x_cur + he));
-
-        t.obj->carriable    = false;
-        t.obj->interactable = false;
-        t.obj->solid        = _collidable;
-        t.obj->body.mode    = _collidable ? PhysicsMode::Static
-                                          : PhysicsMode::Hover;
-        t.obj->body.restitution = 0.55;
-
-        _words.push_back(std::move(t.obj));
-        _anchor_pos.push_back(Vec3{});
-        _jiggle_off.push_back(Vec3{});
-        _jiggle_vel.push_back(Vec3{});
-
-        x_cur += he * 2.0f + ConfigText3d::word_gap;
-    }
-}
-
-
-Vec3
-TextLabel::get_CurrentCentre() const
-{
-    if (_mode == PositionMode::Anchor && _anchor_ptr) {
-        return {
-            _anchor_ptr->x + _attach_offset.x,
-            _anchor_ptr->y + _attach_offset.y,
-            _anchor_ptr->z + _attach_offset.z
-        };
-    }
-    return _centre;
-}
-
-
-void
-TextLabel::update(double dt, const Camera& camera)
-{
-    if (_words.empty()) return;
-
-    Vec3  centre           = get_CurrentCentre();
-    Vec3  right            {1, 0, 0};
-    float yaw_for_glyphs   = 0.0f;
-    float pitch_for_glyphs = 0.0f;
-
-    if (_billboard) {
-        Vec3   forward = camera.pos - centre;
-        double flen    = Math::length(forward);
-        if (flen < 0.0001) return;
-
-        forward = forward / flen;
-
-        right = Math::cross(Vec3{0, 1, 0}, forward);
-        double rlen = Math::length(right);
-        if (rlen < 0.0001) return;
-        right = right / rlen;
-
-        yaw_for_glyphs = SDL_atan2f(static_cast<float>(forward.x),
-                                    static_cast<float>(forward.z));
-
-        pitch_for_glyphs = -SDL_asinf(static_cast<float>(forward.y));
-    } else {
-        yaw_for_glyphs   = _face_yaw;
-        pitch_for_glyphs = 0.0f;
-        right = { SDL_cos(_face_yaw), 0.0, -SDL_sin(_face_yaw) };
-    }
-
-    for (size_t i = 0; i < _words.size(); ++i) {
-        Text3d& t = *_words[i];
-        if (!t.loaded) continue;
-
-        double off = _offsets[i];
-
-        Vec3 anchor{
-            centre.x + right.x * off,
-            centre.y + right.y * off,
-            centre.z + right.z * off
-        };
-        _anchor_pos[i] = anchor;
-
-        Vec3& off3 = _jiggle_off[i];
-        Vec3& vel3 = _jiggle_vel[i];
-
-        Vec3 acc{
-            -ConfigText3d::jiggle_stiff * off3.x - ConfigText3d::jiggle_damp * vel3.x,
-            -ConfigText3d::jiggle_stiff * off3.y - ConfigText3d::jiggle_damp * vel3.y,
-            -ConfigText3d::jiggle_stiff * off3.z - ConfigText3d::jiggle_damp * vel3.z
-        };
-        vel3.x += acc.x * dt;
-        vel3.y += acc.y * dt;
-        vel3.z += acc.z * dt;
-        off3.x += vel3.x * dt;
-        off3.y += vel3.y * dt;
-        off3.z += vel3.z * dt;
-
-        auto clamp = [](double v, double lo, double hi) {
-            return v < lo ? lo : (v > hi ? hi : v);
-        };
-        off3.x = clamp(off3.x, -ConfigText3d::jiggle_max_off, ConfigText3d::jiggle_max_off);
-        off3.y = clamp(off3.y, -ConfigText3d::jiggle_max_off, ConfigText3d::jiggle_max_off);
-        off3.z = clamp(off3.z, -ConfigText3d::jiggle_max_off, ConfigText3d::jiggle_max_off);
-
-        t.pos.x = anchor.x + off3.x;
-        t.pos.y = anchor.y + off3.y;
-        t.pos.z = anchor.z + off3.z;
-        t.yaw   = yaw_for_glyphs;
-        t.pitch = pitch_for_glyphs;
-    }
-}
-
-
-void
-TextLabel::draw(GlRenderer& gl, const Camera& camera, const Light& light) const
-{
-    for (const Text3dPtr& w : _words) {
-        if (!w || !w->loaded) continue;
-        w->draw(gl, camera, light);
-    }
-}
-
-
-void
-TextLabel::kick(size_t word_index, const Vec3& projectile_vel, const Vec3& normal)
-{
-    if (word_index >= _words.size()) return;
-
-    double v_into = -(projectile_vel.x * normal.x +
-                      projectile_vel.y * normal.y +
-                      projectile_vel.z * normal.z);
-    if (v_into < 0.0) v_into = 0.0;
-
-    double mag = v_into * ConfigText3d::kick_strength;
-
-    Vec3& vel3 = _jiggle_vel[word_index];
-    vel3.x += -normal.x * mag;
-    vel3.y += -normal.y * mag;
-    vel3.z += -normal.z * mag;
-}
diff --git a/src/text_3d/text_label.hpp b/src/text_3d/text_label.hpp
deleted file mode 100644
index 1a83f94..0000000
--- a/src/text_3d/text_label.hpp
+++ /dev/null
@@ -1,71 +0,0 @@
-#pragma once
-
-#include <string>
-
-#include "entities/camera.hpp"
-#include "text_3d/text_3d.hpp"
-#include "utils/globals.hpp"
-#include "utils/light.hpp"
-
-
-class GlRenderer;
-
-
-class TextLabel
-{
-    enum class PositionMode : uint8_t
-    {
-        Absolute,
-        Anchor,
-    };
-
-    bool  _collidable { true };
-    bool  _billboard  { true };
-
-    float _style_depth{ 0.06f };
-    float _style_scale{ 0.003f };
-    float _face_yaw   { 0.0f };
-    SDL_FColor  _style_color{ 1.0f, 1.0f, 0.0f, 1.0f };
-
-    const Vec3* _anchor_ptr   { nullptr };
-    Vec3        _centre       {};
-    Vec3        _attach_offset{};
-
-    std::string  _current_text;
-
-    PositionMode _mode { PositionMode::Absolute };
-
-    DynArray<Text3dPtr> _words;
-    DynArray<double>    _offsets;
-    DynArray<Vec3>      _anchor_pos;
-    DynArray<Vec3>      _jiggle_off;
-    DynArray<Vec3>      _jiggle_vel;
-
-
-    void rebuild(const std::string& text);
-    Vec3 get_CurrentCentre() const;
-
-public:
-    TextLabel() = default;
-
-    void clear         ();
-    void update        (double dt, const Camera& camera);
-    void draw          (GlRenderer& gl, const Camera& camera, const Light& light) const;
-    void kick          (size_t word_index, const Vec3& projectile_vel, const Vec3& normal);
-
-    void attach_To     (const Vec3* anchor, const Vec3& offset = {});
-    void detach        ();
-
-    void set_Centre    (const Vec3& p);
-    void set_Billboard (bool on) { _billboard = on; }
-    void set_FaceYaw   (float y) { _face_yaw = y;   }
-    void set_Style     (SDL_FColor color, float depth, float scale);
-    void set_Collidable(bool on);
-    void set_Text      (const std::string& text);
-    void set_Text      (const std::string& text,
-                        SDL_FColor color, float depth, float scale);
-
-    bool is_Empty      () const { return _words.empty(); }
-    const DynArray<Text3dPtr>&
-         get_Words     () const { return _words; }
-};
diff --git a/src/text_3d/vim_buffer.cpp b/src/text_3d/vim_buffer.cpp
deleted file mode 100644
index b67aa1c..0000000
--- a/src/text_3d/vim_buffer.cpp
+++ /dev/null
@@ -1,228 +0,0 @@
-#include "vim_buffer.hpp"
-
-
-void
-VimBufferOLD::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
-VimBufferOLD::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
-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 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 VimBufferOLD::key_escape()
-{
-    if (_mode == Mode::Command) { cancel_Command(); return; }
-    _mode = Mode::Normal;
-    if (_col > 0) --_col;
-    clamp_Col(); touch();
-}
-
-
-VimBufferOLD::Signal
-VimBufferOLD::take_Signal()
-{ Signal s = _sig; _sig = Signal::None; return s; }
-
-
-void
-VimBufferOLD::mark_Saved()
-{ _saved = _lines; _dirty = false; touch(); }
-
-
-void
-VimBufferOLD::touch()
-{ ++_version; }
-
-int 
-VimBufferOLD::get_LineLen() const
-{ return (int)_lines[(size_t)_row].size(); }
-
-int
-VimBufferOLD::get_ClampRow(int r) const
-{ int n=(int)_lines.size(); return r<0?0:(r>=n?n-1:r); }
-
-void
-VimBufferOLD::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
-VimBufferOLD::get_Eff() const
-{ return _count > 0 ? _count : 1; }
-
-void
-VimBufferOLD::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
-VimBufferOLD::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
-VimBufferOLD::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
-VimBufferOLD::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
-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; }
-    clamp_Col();
-}
-
-void
-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; }
-        _lines.erase(_lines.begin() + _row);
-        if (_row >= (int)_lines.size()) _row = (int)_lines.size() - 1;
-        _dirty = true;
-    }
-    clamp_Col();
-}
-void
-VimBufferOLD::open_Below()
-{ _lines.insert(_lines.begin() + _row + 1, ""); ++_row; _col = 0; _dirty = true; }
-
-void
-VimBufferOLD::open_Above()
-{ _lines.insert(_lines.begin() + _row,     ""); _col = 0; _dirty = true; }
-
-void
-VimBufferOLD::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
-VimBufferOLD::cancel_Command()
-{ _cmd.clear(); _mode = Mode::Normal; touch(); }
-
-bool
-VimBufferOLD::is_Dirty() const
-{ return _dirty; }
diff --git a/src/text_3d/vim_buffer.hpp b/src/text_3d/vim_buffer.hpp
deleted file mode 100644
index d470e73..0000000
--- a/src/text_3d/vim_buffer.hpp
+++ /dev/null
@@ -1,76 +0,0 @@
-#pragma once
-
-#include <algorithm>
-#include <cstdint>
-#include <string>
-#include <vector>
-
-class VimBufferOLD {
-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 ();
-
-    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/utils/globals.hpp b/src/utils/globals.hpp
index ed1d089..d225217 100644
--- a/src/utils/globals.hpp
+++ b/src/utils/globals.hpp
@@ -12,14 +12,16 @@
 class  GlRenderer;
 struct CarriedWordNode;
 struct GameObject;
-struct Text3d;
+namespace Text { struct Token; }
 
 using Math::Vec3;
 
-template<typename T> 
+template<typename T>
 using DynArray           = std::vector<T>;
 
 using GlRendererPtr      = cpputils::unique_ptr<GlRenderer>;
-using Text3dPtr          = cpputils::unique_ptr<Text3d>;
 using CarriedWordNodePtr = cpputils::unique_ptr<CarriedWordNode>;
 using GameObjectPtr      = cpputils::unique_ptr<GameObject>;
+
+using Text3d    = Text::Token;
+using Text3dPtr = cpputils::unique_ptr<Text::Token>;
diff --git a/src/utils/text.cpp b/src/utils/text.cpp
index aba7de4..991604f 100644
--- a/src/utils/text.cpp
+++ b/src/utils/text.cpp
@@ -91,18 +91,18 @@ ascii_Sanitize(const std::string& in)
 
 
 void
-View::clear()
+SrcView::clear()
 { lines.clear(); }
 
 
 std::string
-View::render_Source()
+SrcView::render_Source()
 {
     std::string out;
-    for (Line& line : lines) {
+    for (SrcLine& line : lines) {
         for (size_t ti = 0; ti < line.tokens.size(); ++ti) {
             if (ti) out += ' ';
-            Token& tk = line.tokens[ti];
+            SrcToken& tk = line.tokens[ti];
             tk.byte_begin = out.size();
             out += tk.text;
             tk.byte_end = out.size();
@@ -113,11 +113,11 @@ View::render_Source()
 }
 
 
-Token*
-View::token_At(size_t off)
+SrcToken*
+SrcView::token_At(size_t off)
 {
-    for (Line& line : lines)
-        for (Token& tk : line.tokens)
+    for (SrcLine& line : lines)
+        for (SrcToken& tk : line.tokens)
             if (off >= tk.byte_begin && off < tk.byte_end)
                 return &tk;
     return nullptr;
@@ -125,10 +125,10 @@ View::token_At(size_t off)
 
 
 void
-View::apply_Spans(const DynArray<HiSpan>& spans)
+SrcView::apply_Spans(const DynArray<HiSpan>& spans)
 {
-    for (Line& line : lines) {
-        for (Token& tk : line.tokens) {
+    for (SrcLine& line : lines) {
+        for (SrcToken& tk : line.tokens) {
             if (!tk.view) continue;
 
             size_t best_overlap = 0;
@@ -148,10 +148,10 @@ View::apply_Spans(const DynArray<HiSpan>& spans)
 }
 
 DynArray<std::string>
-View::as_Lines() const
+SrcView::as_Lines() const
 {
     DynArray<std::string> out;
-    for (const Line& line : lines) {
+    for (const SrcLine& line : lines) {
         std::string s;
         for (size_t ti = 0; ti < line.tokens.size(); ++ti) {
             if (ti) s += ' ';
diff --git a/src/utils/text.hpp b/src/utils/text.hpp
index 57e0522..aaa1051 100644
--- a/src/utils/text.hpp
+++ b/src/utils/text.hpp
@@ -18,8 +18,13 @@ std::string
     ascii_Sanitize(const std::string& in);
 
 
+// ── Source view (temporary) ────────────────────────────────────────────────
+// Renamed from Token/Line/View to Src* so it no longer collides with the new
+// Text::Token. This whole trio is replaced by Text::Handler and will be deleted
+// once the editor cutover lands; it survives one step purely to keep the build
+// green during the type unification.
 
-struct Token
+struct SrcToken
 {
     std::string text;
     size_t      byte_begin { 0 };
@@ -28,19 +33,18 @@ struct Token
 };
 
 
-struct Line
+struct SrcLine
 {
-    DynArray<Token> tokens;
+    DynArray<SrcToken> tokens;
 };
 
-struct View
+struct SrcView
 {
-
-    DynArray<Line> lines;
+    DynArray<SrcLine> lines;
 
     void        clear        ();
     std::string render_Source();
-    Token*      token_At     (size_t off);
+    SrcToken*   token_At     (size_t off);
     void        apply_Spans  (const DynArray<HiSpan>& spans);
     DynArray<std::string>
                 as_Lines     () const;
diff --git a/src/wall/label.cpp b/src/wall/label.cpp
index dde80d5..d2e4ee1 100644
--- a/src/wall/label.cpp
+++ b/src/wall/label.cpp
@@ -1,8 +1,10 @@
 #include "entities/camera.hpp"
 #include "rendering/gl_renderer.hpp"
 #include "utils/config.hpp"
+#include "wall/label.hpp"
 #include "wall/wall.hpp"
 
+
 // ── Label appearance ──────────────────────────────────────────────────────────
 
 namespace {
@@ -100,7 +102,7 @@ Wall::Label::on_Hit(size_t word_index, const Vec3& projectile_vel)
 void
 Wall::Label::update(double dt, const Camera& camera)
 {
-    // Positions + jiggle spring first. In non-billboard mode TextLabel::update
+    // Positions + jiggle spring first. In non-billboard mode Text::Handler::update
     // forces every word's pitch to 0, so we must apply our eye-tilt AFTER it,
     // not before — otherwise the tilt gets clobbered. Pitch doesn't affect the
     // positions update() computes, so ordering it last is safe.
diff --git a/src/wall/label.hpp b/src/wall/label.hpp
new file mode 100644
index 0000000..38d9415
--- /dev/null
+++ b/src/wall/label.hpp
@@ -0,0 +1,29 @@
+#pragma once
+//
+// Wall::Label — a per-wall billboard name tag. Lives in its own header (not
+// wall.hpp) because it now owns a Text::Handler by value, and handler.hpp
+// includes wall.hpp; keeping Label here avoids the include cycle.
+
+#include "text/handler.hpp"
+#include "wall/wall.hpp"
+
+class  Camera;
+class  GlRenderer;
+struct Light;
+
+namespace Wall
+{
+    struct Label
+    {
+        Wall::Role    role;
+        float         face_yaw;
+        Vec3          anchor;
+        ::Text::Handler label;
+
+        static DynArray<Label> generate_All(float world_scale);
+
+        void on_Hit (size_t word_index, const Vec3& projectile_vel);
+        void update (double dt, const Camera& camera);
+        void draw   (GlRenderer& gl, const Camera& camera, const Light& light) const;
+    };
+}
diff --git a/src/wall/text.cpp b/src/wall/text.cpp
index 920c0f3..6bc906d 100644
--- a/src/wall/text.cpp
+++ b/src/wall/text.cpp
@@ -1,5 +1,6 @@
 #include "entities/world.hpp"
 #include "systems/cpp_highlight_system.hpp"
+#include "text/handler.hpp"
 #include "utils/config.hpp"
 #include "wall/wall.hpp"
 
@@ -129,32 +130,20 @@ Text::draw(GlRenderer& g, const Camera& c, const Light& l) const
 
 
 void
-Text::show_TokenInfo(PtrList& texts, const std::string& token_text)
+Text::show_TokenInfo(::Text::Handler& info, const std::string& token_text)
 {
-    texts.erase(
-        std::remove_if(texts.begin(), texts.end(),
-            [](const Ptr& wt){ return wt->role == Wall::Role::Info; }),
-        texts.end());
-
-    Wall::Transform tf = Wall::get_Transform(Wall::Role::Info, World::get_BoxScale());
-
-    TokenType        tt = CppHighlight::classify_Word(token_text);
-    TokenDB::Desc    d  = TokenDB::get_Description(token_text, tt);
-
-    std::string line1 = token_text + "  [" + TokenDB::get_TypeName(tt) + "]";
-    std::string line2 = d.short_desc;
-    std::string line3 = d.long_desc;
-
-    float y = -14.0f;
-    for (const std::string& line : { line1, line2, line3 }) {
-        if (line.empty()) { y -= ConfigWall::text_line_spacing; continue; }
-        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<Text>(Text{
-            tf.wall_position, Wall::Role::Info, std::move(t) }));
-        y -= ConfigWall::text_line_spacing;
-    }
+    info.clear();
+
+    TokenType     tt = CppHighlight::classify_Word(token_text);
+    TokenDB::Desc d  = TokenDB::get_Description(token_text, tt);
+
+    const std::string line1 = token_text + "  [" + TokenDB::get_TypeName(tt) + "]";
+    const std::string line2 = d.short_desc;
+    const std::string line3 = d.long_desc;
+
+    // Bottom-aligned: each append pushes the prior lines up, so line1 ends on top.
+    for (const std::string& line : { line1, line2, line3 })
+        if (!line.empty()) info.append_Line(line);
 }
 
 
@@ -177,52 +166,4 @@ Text::tilt_TowardEye(const Vec3& text_pos, const Vec3& eye_pos)
 }
 
 
-void
-Text::clear_ToFloor(Wall::PtrList& texts,
-               DynArray<Text3dPtr>& words,
-               Wall::Role role)
-{
-    for (Wall::Ptr& wt : texts) {
-        if (wt->role != role || !wt->text3d) continue;
-
-        Text3dPtr word  = std::move(wt->text3d);
-        word->body.mode = PhysicsMode::Dynamic;
-        word->body.vel  = { 0.0, 1.5, 0.0 };
-        word->body.hover_height = World::get_FloorY<double>() + 1.5;
-
-        word->set_Look(ConfigColor::floor, Text3d::depth_floor);
-
-        words.push_back(std::move(word));
-    }
-
-    texts.erase(
-        std::remove_if(texts.begin(), texts.end(),
-            [role](const Wall::Ptr& wt){
-                return wt->role == role && !wt->text3d;
-            }),
-        texts.end());
-}
-
-
-void
-Text::scroll(Wall::PtrList& texts, Wall::Role role, float delta)
-{
-    for (Wall::Ptr& wt : texts) {
-        if (wt->role == role && wt->text3d) {
-            wt->text3d->pos.y += static_cast<double>(delta);
-        }
-    }
-}
-
-
-void
-Text::scroll_Up  (Wall::PtrList& texts, Wall::Role role)
-{ scroll(texts, role,  ConfigWall::scroll_step); }
-
-
-void
-Text::scroll_Down(Wall::PtrList& texts, Wall::Role role)
-{ scroll(texts, role, -ConfigWall::scroll_step); }
-
-
 } // namespace Wall
diff --git a/src/wall/wall.hpp b/src/wall/wall.hpp
index 08c349a..3f4ca42 100644
--- a/src/wall/wall.hpp
+++ b/src/wall/wall.hpp
@@ -1,11 +1,12 @@
 #pragma once
 
 #include "text_3d/text_3d.hpp"
-#include "text_3d/text_label.hpp"
 #include "utils/config.hpp"
 #include "utils/globals.hpp"
 
 
+namespace Text { class Handler; }
+
 namespace Wall
 {
     struct Text;
@@ -56,32 +57,10 @@ namespace Wall
 
         void draw (GlRenderer& gl, const Camera& camera, const Light& light) const;
 
-        static void        show_TokenInfo(PtrList& texts, const std::string& token_text);
+        static void        show_TokenInfo(::Text::Handler& info, const std::string& token_text);
         static Ptr         from_Drop     (Text3dPtr src);
         static std::string extract_Text  (const PtrList&, Wall::Role, double line_threshold = 0.3);
         static float       tilt_TowardEye(const Vec3& text_pos, const Vec3& eye_pos);
-        static void        clear_ToFloor (Wall::PtrList& texts,
-                                          DynArray<Text3dPtr>& words,
-                                          Wall::Role role);
-        static void        scroll        (Wall::PtrList& texts, Wall::Role role, float delta);
-        static void        scroll_Up     (Wall::PtrList& texts, Wall::Role role);
-        static void        scroll_Down   (Wall::PtrList& texts, Wall::Role role);
     };
 
-
-
-    struct Label
-    {
-        Wall::Role role;
-        float      face_yaw;
-        Vec3       anchor;
-        TextLabel  label;
-
-        static DynArray<Label>
-            generate_All(float world_scale);
-
-        void on_Hit (size_t word_index, const Vec3& projectile_vel);
-        void update (double dt, const Camera& camera);
-        void draw   (GlRenderer& gl, const Camera& camera, const Light& light) const;
-    };
 }