Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / 369de48

sdl_runtime_compiler git · main

SDL3 game for running and compiling code at runtime

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

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

Some major refractoring and cleanup Move Text stuff

Christian Immanuel · 2026-06-09 17:24 · 369de48aa172befee40f29168db18cb7136df388

 benchmark/bench_ai.cpp                    |   8 +-
 benchmark/bench_text_mesh.cpp             |   2 +-
 src/cpp_token/cpp_highlight.hpp           |  19 -
 src/cpp_token/token.hpp                   |  29 --
 src/cpp_token/token_color.hpp             |  31 --
 src/cpp_token/token_db.hpp                |  20 -
 src/cpp_token/ts_test.cpp                 |  21 -
 src/entities/player.hpp                   |   1 -
 src/entities/pult.cpp                     |   2 +-
 src/levels/director.cpp                   |  20 +-
 src/levels/level/tutorial.cpp             |   2 +-
 src/levels/scene.hpp                      |   4 +-
 src/rendering/gl_renderer.cpp             |   2 +-
 src/states/game_context.cpp               |  32 +-
 src/states/game_context.hpp               |  10 +-
 src/states/game_state.cpp                 | 197 ++++------
 src/states/game_state.hpp                 |   7 +-
 src/states/menu_state.cpp                 |   4 +-
 src/states/menu_state.hpp                 |   6 +-
 src/systems/aim.hpp                       |  16 +-
 src/systems/carry_system.cpp              |  18 +-
 src/systems/carry_system.hpp              |  12 +-
 src/systems/celebration_system.cpp        |   4 +-
 src/systems/celebration_system.hpp        |   3 +-
 src/systems/compiler_system.cpp           |   2 +-
 src/systems/cpp_highlight_system.hpp      |   8 +-
 src/systems/dyn_loader_system.cpp         |   2 +-
 src/systems/game_mod_abi.h                |   2 +-
 src/systems/hint_system.cpp               |   2 +-
 src/systems/physics_system.cpp            |  20 +-
 src/systems/physics_system.hpp            |   2 +-
 src/systems/pick_system.cpp               |  23 +-
 src/systems/pick_system.hpp               |  25 +-
 src/systems/program_system.cpp            |   6 +-
 src/systems/program_system.hpp            |   2 +-
 src/systems/projectile_system.cpp         |  36 +-
 src/systems/projectile_system.hpp         |  10 +-
 src/systems/wall_connection.cpp           |   8 +-
 src/systems/wall_connection.hpp           |  14 +-
 src/systems/word_ai_system.cpp            |  86 ++--
 src/systems/word_ai_system.hpp            |  36 +-
 src/systems/word_anim.cpp                 |   4 +-
 src/systems/word_anim.hpp                 |  14 +-
 src/{cpp_token => text}/cpp_highlight.cpp |  47 +--
 src/text/handler.cpp                      | 190 ++++-----
 src/text/handler.hpp                      | 199 ++++------
 src/text/line.cpp                         |   2 +-
 src/text/line.hpp                         |  35 +-
 src/text/text.cpp                         |  91 +++++
 src/text/text.hpp                         |  88 +++++
 src/{text_3d => text}/text_mesh.cpp       |   0
 src/{text_3d => text}/text_mesh.hpp       |   0
 src/text/token.cpp                        |  78 ++--
 src/text/token.hpp                        | 251 ++++++------
 src/{cpp_token => text}/token_db.cpp      | 111 ++++--
 src/text_3d/text_3d.hpp                   |  25 --
 src/utils/frame_profiler.hpp              |   2 +-
 src/utils/globals.hpp                     |  31 +-
 src/utils/text.cpp                        | 165 --------
 src/utils/text.hpp                        |  53 ---
 src/wall/editor.cpp                       | 626 ------------------------------
 src/wall/editor.hpp                       |  84 ----
 src/wall/label.cpp                        |  10 +-
 src/wall/label.hpp                        |   2 +-
 src/wall/text.cpp                         | 169 --------
 src/wall/wall.cpp                         |  68 ++++
 src/wall/wall.hpp                         |  44 ++-
 67 files changed, 992 insertions(+), 2151 deletions(-)

diff --git a/benchmark/bench_ai.cpp b/benchmark/bench_ai.cpp
index 8d8c6fc..afbf0ee 100644
--- a/benchmark/bench_ai.cpp
+++ b/benchmark/bench_ai.cpp
@@ -4,7 +4,7 @@
 #include "systems/word_ai_system.hpp"
 #include "systems/carry_system.hpp"
 #include "entities/player.hpp"
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 
 // What's the per-frame cost of my AI system, and does it scale with the number of words?
 
@@ -18,17 +18,17 @@ static void BM_WordAiUpdate(benchmark::State& state)
 
     CarriedLine carried;
 
-    std::vector<Text3dPtr> words;
+    DynArray<Text::TokenPtr> words;
     words.reserve(N);
     for (size_t i = 0; i < N; ++i) {
         double angle = (static_cast<double>(i) / N) * 6.28318;
         Vec3 pos { 5.0 * SDL_cos(angle), 0.0, 5.0 * SDL_sin(angle) };
-        words.push_back(make_Text3d("hover", Text3d::depth_floor, 0.003f, pos));
+        words.push_back(Text::make_Token("hover", Text::Token::depth_floor, 0.003f, pos));
     }
 
     WordAiSystem ai;
 
-    std::vector<Text3dPtr> dropped;
+    DynArray<Text::TokenPtr> dropped;
     constexpr double dt = 1.0 / 60.0;
 
     for (auto _ : state) {
diff --git a/benchmark/bench_text_mesh.cpp b/benchmark/bench_text_mesh.cpp
index 029ffcc..02af821 100644
--- a/benchmark/bench_text_mesh.cpp
+++ b/benchmark/bench_text_mesh.cpp
@@ -1,7 +1,7 @@
 #include <benchmark/benchmark.h>
 #include <string>
 
-#include "text_3d/text_mesh.hpp"
+#include "text/text_mesh.hpp"
 
 static const std::string FONT = "/usr/share/fonts/dejavu/DejaVuSansMono.ttf";
 
diff --git a/src/cpp_token/cpp_highlight.hpp b/src/cpp_token/cpp_highlight.hpp
deleted file mode 100644
index 9bd26d7..0000000
--- a/src/cpp_token/cpp_highlight.hpp
+++ /dev/null
@@ -1,19 +0,0 @@
-#pragma once
-#include <string>
-#include "cpp_token/token.hpp"
-#include "utils/globals.hpp"
-
-
-struct HiSpan
-{
-    size_t    byte_begin;
-    size_t    byte_end;
-    TokenType type;
-};
-
-
-namespace CppHighlight
-{
-    DynArray<HiSpan> classify     (const std::string& source);
-    TokenType        classify_Word(const std::string& word);
-}
diff --git a/src/cpp_token/token.hpp b/src/cpp_token/token.hpp
deleted file mode 100644
index 700ff85..0000000
--- a/src/cpp_token/token.hpp
+++ /dev/null
@@ -1,29 +0,0 @@
-#pragma once
-
-#include <cstdint>
-#include <string>
-
-enum class TokenType : uint8_t
-{
-    Delimiter,
-    Identifier,
-    Keyword,
-    Literal,
-    Operator,
-    Preprocessor,
-    Type,
-    Function,
-    Variable,
-    Comment,
-    StringLit,
-    NumberLit,
-    Namespace,
-};
-
-struct Token
-{
-    std::string text;
-    TokenType   type;
-    std::string short_desc;
-    std::string long_desc;
-};
diff --git a/src/cpp_token/token_color.hpp b/src/cpp_token/token_color.hpp
deleted file mode 100644
index 1127b25..0000000
--- a/src/cpp_token/token_color.hpp
+++ /dev/null
@@ -1,31 +0,0 @@
-#pragma once
-#include <SDL3/SDL.h>
-#include "token.hpp"
-#include "token_db.hpp"
-#include "text_3d/text_3d.hpp"
-
-namespace TokenColor
-{
-
-inline SDL_FColor
-get(TokenType t)
-{
-    switch (t) {
-        case TokenType::Keyword:     return ConfigColor::orange;
-        case TokenType::Type:        return ConfigColor::green;
-        case TokenType::Identifier:  return ConfigColor::light_gray;
-        case TokenType::Literal:     return ConfigColor::red;
-        case TokenType::Operator:    return ConfigColor::soft_blue;
-        case TokenType::Delimiter:   return ConfigColor::slate_blue;
-        case TokenType::Preprocessor:return ConfigColor::pink;
-        case TokenType::Function:    return ConfigColor::blue;
-        case TokenType::Variable:    return ConfigColor::pale_sky;
-        case TokenType::Comment:     return ConfigColor::gray;
-        case TokenType::StringLit:   return ConfigColor::lime;
-        case TokenType::NumberLit:   return ConfigColor::amber;
-        case TokenType::Namespace:   return ConfigColor::teal;
-    }
-    return ConfigColor::white;
-}
-
-}
diff --git a/src/cpp_token/token_db.hpp b/src/cpp_token/token_db.hpp
deleted file mode 100644
index dd7ee79..0000000
--- a/src/cpp_token/token_db.hpp
+++ /dev/null
@@ -1,20 +0,0 @@
-#pragma once
-
-#include <string>
-
-#include "token.hpp"
-#include "utils/globals.hpp"
-
-namespace TokenDB
-{
-    struct Desc
-    {
-        std::string short_desc;
-        std::string long_desc;
-    };
-
-    const char* get_TypeName   (TokenType type);
-    Desc        get_Description(const std::string& text, TokenType type);
-    DynArray<std::string>
-                get_TestArrayAllKeywords();
-}
diff --git a/src/cpp_token/ts_test.cpp b/src/cpp_token/ts_test.cpp
deleted file mode 100644
index c78c0a6..0000000
--- a/src/cpp_token/ts_test.cpp
+++ /dev/null
@@ -1,21 +0,0 @@
-#include <tree_sitter/api.h>
-#include <SDL3/SDL.h>
-#include <string>
-
-extern "C" const TSLanguage* tree_sitter_cpp(void);
-
-void ts_LinkTest()
-{
-    TSParser* p = ts_parser_new();
-    ts_parser_set_language(p, tree_sitter_cpp());
-    const std::string src = "int main(){ return 0; }";
-    TSTree* tree = ts_parser_parse_string(p, nullptr, src.c_str(),
-                                          (uint32_t)src.size());
-    TSNode root = ts_tree_root_node(tree);
-    SDL_Log("tree-sitter root node type: %s", ts_node_type(root));
-    char* s = ts_node_string(root);
-    SDL_Log("tree-sitter tree: %s", s);
-    free(s);
-    ts_tree_delete(tree);
-    ts_parser_delete(p);
-}
diff --git a/src/entities/player.hpp b/src/entities/player.hpp
index 0411920..02baa0b 100644
--- a/src/entities/player.hpp
+++ b/src/entities/player.hpp
@@ -3,7 +3,6 @@
 #include <SDL3/SDL.h>
 
 #include "systems/input_system.hpp"
-#include "text_3d/text_3d.hpp"
 #include "utils/globals.hpp"
 
 
diff --git a/src/entities/pult.cpp b/src/entities/pult.cpp
index acd19bb..a0624af 100644
--- a/src/entities/pult.cpp
+++ b/src/entities/pult.cpp
@@ -65,7 +65,7 @@ Pult::Pult(const std::string& n, Vec3 world_pos,
      , name(n), half_w(half_width), half_d(half_depth)
 {
     label.set_Billboard (true);
-    label.set_Style     (ConfigColor::teal, Text3d::depth_wall, 0.0025f);
+    label.set_Style     (ConfigColor::teal, Text::Token::depth_wall, 0.0025f);
     label.set_Collidable(true);
     label.attach_To     (&pos, Vec3{ 0.0, ConfigPult::label_height, 0.0 });
     label.set_Text      (n);
diff --git a/src/levels/director.cpp b/src/levels/director.cpp
index 4b6b08d..99c4922 100644
--- a/src/levels/director.cpp
+++ b/src/levels/director.cpp
@@ -12,7 +12,7 @@
 #include "systems/celebration_system.hpp"
 #include "systems/particle_system.hpp"
 #include "utils/config.hpp"
-#include "utils/text.hpp"
+#include "text/text.hpp"
 #include "wall/wall.hpp"
 
 
@@ -65,7 +65,7 @@ Director::spawn_Grants(GameContext& ctx)
             static_cast<float>(row) * row_gap
         };
 
-        Text3dPtr obj = make_Text3d(tokens[i], Text3d::depth_floor, 0.003f, pos);
+        Text::TokenPtr obj = Text::make_Token(tokens[i], Text::Token::depth_floor, 0.003f, pos);
         obj->body.hover_height = hover_y;
         ctx.words.push_back(std::move(obj));
     }
@@ -76,7 +76,7 @@ void
 Director::setup_Scene(GameContext& ctx, const Scene::Data& scene)
 {
     for (const Scene::Placed::Word& pw : scene.words) {
-        Text3dPtr t = make_Text3d(pw.text, Text3d::depth_floor, 0.003f, pw.pos, pw.color);
+        Text::TokenPtr t = Text::make_Token(pw.text, Text::Token::depth_floor, 0.003f, pw.pos, pw.color);
         t->body.hover_height = pw.pos.y;
         ctx.words.push_back(std::move(t));
     }
@@ -98,25 +98,25 @@ Director::setup_Scene(GameContext& ctx, const Scene::Data& scene)
     }
 
     for (const Scene::Placed::Ai& a : scene.ai_seeds) {
-        Text3dPtr t = make_Text3d(a.text, Text3d::depth_floor, 0.003f, a.pos, ConfigColor::floor);
+        Text::TokenPtr t = Text::make_Token(a.text, Text::Token::depth_floor, 0.003f, a.pos, ConfigColor::floor);
         t->body.hover_height = a.pos.y;
         t->ai.aggressiveness = a.aggressiveness;
 
         switch (a.initial_state) {
-            case Text3dAI::State::Attack:
-                t->ai.state  = Text3dAI::State::Attack;
+            case Text::Token::Ai::State::Attack:
+                t->ai.state  = Text::Token::Ai::State::Attack;
                 t->body.mode = PhysicsMode::AiHover;
                 t->set_Color(ConfigColor::attack);
                 t->ai.state_timer = 0.0;
                 t->ai.phase_timer = 0.5;
                 break;
-            case Text3dAI::State::Flee:
-                t->ai.state  = Text3dAI::State::Flee;
+            case Text::Token::Ai::State::Flee:
+                t->ai.state  = Text::Token::Ai::State::Flee;
                 t->body.mode = PhysicsMode::AiHover;
                 t->set_Color(ConfigColor::flee);
                 break;
-            case Text3dAI::State::Idle:
-            case Text3dAI::State::Stunned:
+            case Text::Token::Ai::State::Idle:
+            case Text::Token::Ai::State::Stunned:
                 break;
         }
 
diff --git a/src/levels/level/tutorial.cpp b/src/levels/level/tutorial.cpp
index f60398f..33394eb 100644
--- a/src/levels/level/tutorial.cpp
+++ b/src/levels/level/tutorial.cpp
@@ -173,7 +173,7 @@ Carrying several? Tap F to throw the WHOLE line at once.)",
                             .aggressiveness = 1.0,
                             .pos            = { -6.0, World::get_FloorY<double>() + 2.0, -6.0 },
                             .text           = "danger",
-                            .initial_state  = Text3dAI::State::Attack,
+                            .initial_state  = Text::Token::Ai::State::Attack,
                         },
                     },
                     .plates    = {},
diff --git a/src/levels/scene.hpp b/src/levels/scene.hpp
index fc6f5be..4dd66fd 100644
--- a/src/levels/scene.hpp
+++ b/src/levels/scene.hpp
@@ -3,7 +3,7 @@
 #include <SDL3/SDL.h>
 #include <string>
 
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 #include "utils/globals.hpp"
 
 
@@ -47,7 +47,7 @@ namespace Scene
 
         std::string text;
 
-        Text3dAI::State initial_state = Text3dAI::State::Attack;
+        Text::Token::Ai::State initial_state = Text::Token::Ai::State::Attack;
     };
     } // namespace Placed::
 
diff --git a/src/rendering/gl_renderer.cpp b/src/rendering/gl_renderer.cpp
index 8743c81..abde45e 100644
--- a/src/rendering/gl_renderer.cpp
+++ b/src/rendering/gl_renderer.cpp
@@ -826,7 +826,7 @@ GlRenderer::draw_MeshColored(const float* verts,   int vert_count,
                       verts, GL_STATIC_DRAW);
 
     // Re-point attributes at _vbo_c — draw_MeshCached may have repointed
-    // this shared VAO at a per-Text3d VBO.
+    // this shared VAO at a per-Text::Token::Data VBO.
     glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 9*sizeof(float), (void*)0);
     glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 9*sizeof(float), (void*)(3*sizeof(float)));
     glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, 9*sizeof(float), (void*)(6*sizeof(float)));
diff --git a/src/states/game_context.cpp b/src/states/game_context.cpp
index 5b408a2..f83e5ba 100644
--- a/src/states/game_context.cpp
+++ b/src/states/game_context.cpp
@@ -1,41 +1,15 @@
 #include <algorithm>
 
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 #include "states/game_context.hpp"
 
 
 void
-GameContext::drop_WordToFloor(Text3dPtr word, const Vec3& vel)
+GameContext::drop_WordToFloor(Text::TokenPtr word, const Vec3& vel)
 {
     if (!word) return;
-    word->set_Look(ConfigColor::keep, Text3d::depth_floor);
+    word->set_Look(ConfigColor::keep, Text::Token::depth_floor);
     word->body.mode = PhysicsMode::Dynamic;
     word->body.vel  = vel;
     words.push_back(std::move(word));
 }
-
-
-void
-GameContext::clear_WallRole(Wall::Role role)
-{
-    wall_texts.erase(
-        std::remove_if(wall_texts.begin(), wall_texts.end(),
-            [role](const Wall::Ptr& wt){ return wt->role == role; }),
-        wall_texts.end());
-}
-
-
-void
-GameContext::add_WallText(Wall::Position pos, Wall::Role role, Text3dPtr t)
-{
-    wall_texts.push_back(cpputils::make_unique<Wall::Text>(
-        Wall::Text{ pos, role, std::move(t) }));
-}
-
-
-void
-GameContext::remove_WallText(size_t index)
-{
-    if (index < wall_texts.size())
-        wall_texts.erase(wall_texts.begin() + static_cast<std::ptrdiff_t>(index));
-}
diff --git a/src/states/game_context.hpp b/src/states/game_context.hpp
index f0b0065..c774522 100644
--- a/src/states/game_context.hpp
+++ b/src/states/game_context.hpp
@@ -3,7 +3,7 @@
 #include <string>
 
 #include "entities/game_object.hpp"
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 #include "utils/globals.hpp"
 #include "wall/wall.hpp"
 
@@ -28,13 +28,9 @@ struct GameContext
     Text::Handler* editor_wall { nullptr };
     Text::Handler* info_wall   { nullptr };
 
-    DynArray<Text3dPtr>     words;
+    DynArray<Text::TokenPtr>words;
     DynArray<GameObjectPtr> scenery;
     DynArray<PultButton*>   buttons;
-    Wall::PtrList           wall_texts;
 
-    void drop_WordToFloor(Text3dPtr word, const Vec3& vel);
-    void clear_WallRole  (Wall::Role role);
-    void add_WallText    (Wall::Position pos, Wall::Role role, Text3dPtr t);
-    void remove_WallText (size_t index);
+    void drop_WordToFloor(Text::TokenPtr word, const Vec3& vel);
 };
diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index 6db4cf1..bfcc1da 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -10,11 +10,13 @@
 #include "rendering/gl_renderer.hpp"
 #include "systems/aim.hpp"
 #include "systems/celebration_system.hpp"
+#include "systems/cpp_highlight_system.hpp"
 #include "systems/game_mod_abi.h"
 #include "systems/input_system.hpp"
 #include "systems/physics_system.hpp"
 #include "systems/pick_system.hpp"
 #include "systems/source_tokens.hpp"
+#include "text/token.hpp"
 #include "utils/config.hpp"
 #include "utils/frame_profiler.hpp"
 #include "wall/label.hpp"
@@ -58,7 +60,7 @@ GameState::GameState(size_t starting_level)
                              Text::LayoutMode::Snapped);
     _program.set_Lexing(false);
     _program.set_ShowNumbers(false, false);
-    _program.set_DefaultStyle(Text3d::depth_wall, 0.003f,
+    _program.set_DefaultStyle(Text::Token::depth_wall, 0.003f,
                               static_cast<double>(ProgramSystem::line_height),
                               ConfigWall::free_space_dist);
     _program.set_AppendBaseline(static_cast<double>(ProgramSystem::start_y));
@@ -68,7 +70,7 @@ GameState::GameState(size_t starting_level)
                               Text::LayoutMode::Snapped);
     _compiler.set_Lexing(false);
     _compiler.set_ShowNumbers(false, false);
-    _compiler.set_DefaultStyle(Text3d::depth_compiler, 0.003f,
+    _compiler.set_DefaultStyle(Text::Token::depth_compiler, 0.003f,
                                static_cast<double>(CompilerSystem::line_height),
                                ConfigWall::free_space_dist);
     _compiler.set_AppendBaseline(static_cast<double>(CompilerSystem::start_y));
@@ -79,7 +81,7 @@ GameState::GameState(size_t starting_level)
                           Text::LayoutMode::Snapped);
     _info.set_Lexing(false);
     _info.set_ShowNumbers(false, false);
-    _info.set_DefaultStyle(Text3d::depth_wall, 0.003f,
+    _info.set_DefaultStyle(Text::Token::depth_wall, 0.003f,
                            static_cast<double>(ConfigWall::text_line_spacing),
                            ConfigWall::free_space_dist);
     _info.set_AppendBaseline(World::get_FloorY<double>() + 1.0);
@@ -112,7 +114,7 @@ GameState::GameState(size_t starting_level)
 void
 GameState::debug_SpawnWords([[maybe_unused]] size_t n)
 {
-    DynArray<std::string> tokens = TokenDB::get_TestArrayAllKeywords();
+    DynArray<std::string> tokens = Text::Token::get_TestArrayAllKeywords();
 
     static constexpr float spacing = 3.0f;
     static constexpr int   per_row = 8;
@@ -126,7 +128,7 @@ GameState::debug_SpawnWords([[maybe_unused]] size_t n)
             hover_y,
             static_cast<float>(i / per_row) * row_gap
         };
-        Text3dPtr obj = make_Text3d(tokens[i], Text3d::depth_floor, 0.003f, pos);
+        Text::TokenPtr obj = Text::make_Token(tokens[i], Text::Token::depth_floor, 0.003f, pos);
         obj->body.hover_height = hover_y;
         _ctx.words.push_back(std::move(obj));
     }
@@ -192,8 +194,7 @@ GameState::pult_DispatchAction(PultAction act)
 void
 GameState::on_Click()
 {
-    PickResult r = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery,
-                                    _ctx.wall_texts, _text_walls);
+    PickResult r = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery, _text_walls);
     if (!r.hit()) return;
 
     if (r.kind == PickKind::Scenery && r.scenery_obj) {
@@ -206,43 +207,28 @@ GameState::on_Click()
 bool
 GameState::on_Pick()
 {
-    PickResult r = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery,
-                                    _ctx.wall_texts, _text_walls);
+    PickResult r = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery, _text_walls);
     if (!r.hit()) return false;
 
     if (r.kind == PickKind::WallWord && r.wall_handler && r.wall_token) {
-        Text3dPtr base = r.wall_handler->detach(r.wall_token);
+        Text::TokenPtr 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;
-
-        if (base->type == ObjectType::Text) {
-            _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);
+        _level_director-> record_PickedUp(base->text);
+        base->set_Look       (ConfigColor::keep, Text::Token::depth_floor);
+        Wall::show_TokenInfo (_info, base->text);
+        carry_BeginAnim      (std::move(base), true);
         return true;
     }
 
     if (r.kind == PickKind::Word) {
-        Text3dPtr word = std::move(_ctx.words[r.word_index]);
+        Text::TokenPtr word = std::move(_ctx.words[r.word_index]);
         _ctx.words.erase(_ctx.words.begin() + static_cast<std::ptrdiff_t>(r.word_index));
         if (!word) return false;
 
         _level_director->record_PickedUp(word->text);
-        word->set_Look(ConfigColor::keep, Text3d::depth_floor);
-        Wall::Text::show_TokenInfo(_info, word->text);
+        word->set_Look      (ConfigColor::keep, Text::Token::depth_floor);
+        Wall::show_TokenInfo(_info, word->text);
 
         carry_BeginAnim(std::move(word), false);
         return true;
@@ -256,7 +242,7 @@ GameState::on_DropLast()
 {
     if (_carried.empty()) return;
 
-    Text3dPtr word = _carried.pop_back();
+    Text::TokenPtr word = _carried.pop_back();
     if (!word) return;
 
     infowall_Clear();
@@ -269,7 +255,7 @@ GameState::on_DropLast()
     _anims_drop.push_back(std::move(drop));
 
     if (!_carried.empty())
-        Wall::Text::show_TokenInfo(_info, _carried.last->word->text);
+        Wall::show_TokenInfo(_info, _carried.last->word->text);
     else
         _level_director->show_Details(_ctx);
 
@@ -282,7 +268,7 @@ GameState::on_ShootOne()
 {
     if (_carried.empty()) return false;
 
-    Text3dPtr word = _carried.pop_back();
+    Text::TokenPtr word = _carried.pop_back();
     if (!word) return false;
 
     infowall_Clear();
@@ -290,16 +276,16 @@ GameState::on_ShootOne()
     _freelook_sys->state.active  = false;
     _freelook_sys->state.lerping = false;
 
-    DynArray<Text3dPtr> single;
+    DynArray<Text::TokenPtr> single;
     single.push_back(std::move(word));
 
     Aim::Result aim = Aim::crosshair_Target(*_ctx.camera, _ctx.words, _ctx.scenery,
-                                             _ctx.wall_texts, _text_walls, World::get_BoxScale());
+                                             _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(_info, _carried.last->word->text);
+        Wall::show_TokenInfo(_info, _carried.last->word->text);
     else _level_director->show_Details(_ctx);
 
     _level_director->record_Shot();
@@ -317,7 +303,7 @@ GameState::on_ShootAll()
     _freelook_sys->state.active  = false;
     _freelook_sys->state.lerping = false;
 
-    DynArray<Text3dPtr> all_words;
+    DynArray<Text::TokenPtr> all_words;
     for (CarriedWordNode* node : _carried.nodes())
         if (node->word)
             all_words.push_back(std::move(node->word));
@@ -325,13 +311,13 @@ GameState::on_ShootAll()
     _carried = CarriedLine();
     if (!all_words.empty()) {
         Aim::Result aim = Aim::crosshair_Target(*_ctx.camera, _ctx.words, _ctx.scenery,
-                                                 _ctx.wall_texts, _text_walls, World::get_BoxScale());
+                                                 _text_walls, World::get_BoxScale());
         _projectiles_sys->launch(_projectiles, std::move(all_words),
                                  _ctx.camera->fwd, _ctx.camera->pos, aim);
     }
 
     _level_director->show_Details(_ctx);
-    _level_director->record_Shot();
+    _level_director->record_Shot ();
 }
 
 
@@ -375,9 +361,6 @@ void
 GameState::on_DebugPrint()
 {
     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);
 }
 
 
@@ -413,7 +396,7 @@ void
 GameState::on_NumberAdjust(int delta)
 {
     if (_carried.empty() || !_carried.last || !_carried.last->word) return;
-    Text3d* w = _carried.last->word.get();
+    Text::Token::Data* w = _carried.last->word.get();
 
     const std::string& s = w->text;
     if (s.empty()) return;
@@ -461,7 +444,7 @@ void
 GameState::projectile_FeedProgramInput(ProjectileResult& result)
 {
     std::string line;
-    for (const Text3dPtr& w : result.words) {
+    for (const Text::TokenPtr& w : result.words) {
         if (!w || w->type != ObjectType::Text) continue;
         if (!line.empty()) line += ' ';
         line += w->text;
@@ -476,9 +459,9 @@ GameState::projectile_FeedProgramInput(ProjectileResult& result)
 void
 GameState::projectile_LandOnFloor(ProjectileResult& result)
 {
-    for (Text3dPtr& w : result.words) {
+    for (Text::TokenPtr& w : result.words) {
         if (!w || w->type != ObjectType::Text) continue;
-        w->set_Look(ConfigColor::keep, Text3d::depth_floor);
+        w->set_Look(ConfigColor::keep, Text::Token::depth_floor);
         w->body.mode = PhysicsMode::Dynamic;
         w->body.vel  = result.vel;
         _ctx.words.push_back(std::move(w));
@@ -489,41 +472,41 @@ GameState::projectile_LandOnFloor(ProjectileResult& result)
 void
 GameState::projectile_LandOnWall(ProjectileResult& result)
 {
-    DynArray<Text3dPtr> editor_block;
+    DynArray<Text::TokenPtr> editor_block;
     std::string prog_line, comp_line, info_line;
 
-    for (Text3dPtr& w : result.words) {
+    for (Text::TokenPtr& w : result.words) {
         if (!w) continue;
         if (w->type == ObjectType::Text)
-            w->set_Look(ConfigColor::keep, Text3d::depth_wall);
+            w->set_Look(ConfigColor::keep, Text::Token::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) continue;
+        Wall::Drop d = Wall::attach_ToNearestWall(std::move(w));
+        if (!d.token) continue;
 
-        switch (wt->role) {
+        switch (d.role) {
             case Wall::Role::Editor:
-                editor_block.push_back(std::move(wt->text3d));
+                editor_block.push_back(std::move(d.token));
                 break;
             case Wall::Role::Program:
                 if (!prog_line.empty()) prog_line += ' ';
-                prog_line += wt->text3d->text;
+                prog_line += d.token->text;
                 break;
             case Wall::Role::Compiler:
                 if (!comp_line.empty()) comp_line += ' ';
-                comp_line += wt->text3d->text;
+                comp_line += d.token->text;
                 break;
             case Wall::Role::Info:
                 if (!info_line.empty()) info_line += ' ';
-                info_line += wt->text3d->text;
+                info_line += d.token->text;
                 break;
         }
     }
 
     if (!editor_block.empty()) {
         Vec3 impact{0, 0, 0};
-        for (const Text3dPtr& t : editor_block)
+        for (const Text::TokenPtr& 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_block.size());
         impact.x *= inv; impact.y *= inv; impact.z *= inv;
@@ -536,13 +519,12 @@ GameState::projectile_LandOnWall(ProjectileResult& result)
 
 
 void
-GameState::projectile_LandOnWallText(ProjectileResult& result,
-                                     DynArray<size_t>& wall_text_to_erase)
+GameState::projectile_LandOnWallText(ProjectileResult& result)
 {
     const size_t idx = result.wall_text_index;
 
     if (idx >= LABEL_HIT_BASE) {
-        for (Text3dPtr& w : result.words)
+        for (Text::TokenPtr& w : result.words)
             if (w) _ctx.drop_WordToFloor(std::move(w), result.vel);
         return;
     }
@@ -559,19 +541,19 @@ GameState::projectile_LandOnWallText(ProjectileResult& result,
             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));
+                    DynArray<Text::TokenPtr> block;
+                    for (Text::TokenPtr& 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())) {
+                if (Text::TokenPtr 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)
+        for (Text::TokenPtr& w : result.words)
             if (w) _ctx.drop_WordToFloor(std::move(w), result.vel);
         return;
     }
@@ -585,29 +567,8 @@ GameState::projectile_LandOnWallText(ProjectileResult& result,
         return;
     }
 
-    walltext_HandleShootDown(result, wall_text_to_erase);
-}
-
-
-void
-GameState::walltext_HandleShootDown(ProjectileResult& result,
-                                  DynArray<size_t>& to_erase)
-{
-    if (result.wall_text_index < _ctx.wall_texts.size()) {
-        Wall::Ptr& wt = _ctx.wall_texts[result.wall_text_index];
-        if (wt && wt->text3d) {
-            Text3dPtr orphan = std::move(wt->text3d);
-            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});
-            to_erase.push_back(result.wall_text_index);
-        }
-    }
-
-    for (Text3dPtr& w : result.words) {
-        if (!w) continue;
-        _ctx.drop_WordToFloor(std::move(w), result.vel);
-    }
+    for (Text::TokenPtr& w : result.words)
+        if (w) _ctx.drop_WordToFloor(std::move(w), result.vel);
 }
 
 
@@ -705,10 +666,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);
-        }
 
         for (Wall::Label& lbl : _wall_labels)
             lbl.update(dt, *_ctx.camera);
@@ -763,7 +720,7 @@ GameState::update(double dt, const GlRenderer* gl)
         if (_editor.is_Dirty())
             _highlight_sys->request(_editor.render_Source(), _editor.generation());
 
-        DynArray<HiSpan> spans;
+        DynArray<Text::HiSpan> spans;
         uint64_t gen;
         if (_highlight_sys->poll(spans, gen))
             _editor.apply_Highlight(spans, gen);
@@ -806,15 +763,10 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
     }
 
     { FP_ZONE("render.words");
-        for (const Text3dPtr& t : _ctx.words)
+        for (const Text::TokenPtr& t : _ctx.words)
             if (t) t->draw(renderer, *_ctx.camera, *_ctx.light);
     }
 
-    { FP_ZONE("render.wall_texts");
-        for (const Wall::Ptr& wt : _ctx.wall_texts)
-            wt->draw(renderer, *_ctx.camera, *_ctx.light);
-    }
-
     { FP_ZONE("render.wall_labels");
         for (const Wall::Label& lbl : _wall_labels)
             lbl.draw(renderer, *_ctx.camera, *_ctx.light);
@@ -835,16 +787,16 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
     }
 
     { FP_ZONE("render.ai.stolen");
-        for (const Text3dPtr& t : _ctx.words) {
+        for (const Text::TokenPtr& t : _ctx.words) {
             if (!t) continue;
-            for (const Text3dPtr& s : t->ai.stolen)
+            for (const Text::TokenPtr& s : t->ai.stolen)
                 if (s) s->draw(renderer, *_ctx.camera, *_ctx.light);
         }
     }
 
     { FP_ZONE("render._projectiles");
         for (const Projectile& proj : _projectiles)
-            for (const Text3dPtr& wd : proj.words)
+            for (const Text::TokenPtr& wd : proj.words)
                 if (wd) wd->draw(renderer, *_ctx.camera, *_ctx.light);
     }
 
@@ -879,16 +831,16 @@ GameState::update_WorldSimulation(double dt)
     DynArray<GameObject*> colliders;
     colliders.reserve(_ctx.scenery.size() + _ctx.words.size());
     for (const GameObjectPtr& p : _ctx.scenery) if (p) colliders.push_back(p.get());
-    for (const Text3dPtr&     p : _ctx.words)   if (p) colliders.push_back(p.get());
+    for (const Text::TokenPtr&     p : _ctx.words)   if (p) colliders.push_back(p.get());
 
     { FP_ZONE("updateworldsim.phys_objects");
         _physics_sys->update_Objects(colliders, dt, 25.0);
     }
 
     { FP_ZONE("updateworldsim.word_ai");
-        DynArray<Text3dPtr> ai_dropped;
+        DynArray<Text::TokenPtr> ai_dropped;
         _word_ai_sys->update(_ctx.words, *_ctx.player, _carried, ai_dropped, dt);
-        for (Text3dPtr& d : ai_dropped) {
+        for (Text::TokenPtr& d : ai_dropped) {
             if (d) _ctx.words.push_back(std::move(d));
         }
     }
@@ -916,18 +868,6 @@ GameState::update_WorldSimulation(double dt)
 
     DynArray<WallTextHitTest> wall_text_hits;
     { FP_ZONE("updateworldsim.build_wall_hits");
-        wall_text_hits.reserve(_ctx.wall_texts.size());
-        for (size_t i = 0; i < _ctx.wall_texts.size(); ++i) {
-            const Wall::Ptr& wt = _ctx.wall_texts[i];
-            if (!wt || !wt->text3d) continue;
-            wall_text_hits.push_back({
-                    wt->wall_position,
-                    i,
-                    wt->text3d->aabb_Min(),
-                    wt->text3d->aabb_Max(),
-                });
-        }
-
         for (size_t h = 0; h < _text_walls.size(); ++h) {
             Text::Handler* hw = _text_walls[h];
             if (!hw) continue;
@@ -951,7 +891,7 @@ GameState::update_WorldSimulation(double dt)
                                        nh.aabb_Min, nh.aabb_Max });
 
         for (size_t li = 0; li < _wall_labels.size(); ++li) {
-            const DynArray<Text3dPtr>& words = _wall_labels[li].label.get_Words();
+            const DynArray<Text::TokenPtr>& words = _wall_labels[li].label.get_Words();
             for (size_t wi = 0; wi < words.size(); ++wi) {
                 if (!words[wi]) continue;
                 wall_text_hits.push_back({
@@ -971,8 +911,6 @@ GameState::update_WorldSimulation(double dt)
                                  _program_sys->is_Running());
     }
 
-    DynArray<size_t> wall_text_to_erase;
-
     { FP_ZONE("updateworldsim.projectile_land");
         for (ProjectileResult& result : proj_results) {
             switch (result.landing) {
@@ -981,16 +919,11 @@ GameState::update_WorldSimulation(double dt)
                                                   projectile_FeedProgramInput(result); break;
                 case ProjectileLanding::Wall:     projectile_LandOnWall      (result); break;
                 case ProjectileLanding::WallText:
-                                                  projectile_LandOnWallText  (result, wall_text_to_erase);
-                                                  break;
+                                                  projectile_LandOnWallText  (result); break;
                 case ProjectileLanding::None:     break;
             }
         }
 
-        std::sort(wall_text_to_erase.begin(), wall_text_to_erase.end(), std::greater<size_t>{});
-        for (size_t idx : wall_text_to_erase)
-            _ctx.remove_WallText(idx);
-
         for (const ProjectileContactEvent& ev : projectile_contacts) {
             if (std::optional<PultAction> act =
                     _pult_sys->handle_ProjectileContact(ev, _ctx.buttons); act) {
@@ -999,9 +932,9 @@ GameState::update_WorldSimulation(double dt)
             }
 
             if (ev.other && ev.other->type == ObjectType::Text) {
-                Text3d* word = static_cast<Text3d*>(ev.other);
+                Text::Token::Data* word = static_cast<Text::Token::Data*>(ev.other);
                 _level_director->record_AiDefeated(word->text);
-                word->ai.state       = Text3dAI::State::Stunned;
+                word->ai.state       = Text::Token::Ai::State::Stunned;
                 word->ai.state_timer = 0.0;
                 word->body.vel       = Vec3{0, 0, 0};
                 word->set_Color(ConfigColor::stunned);
@@ -1065,7 +998,7 @@ GameState::dyn_Clear()
         _ctx.scenery.end());
     _ctx.words.erase(
         std::remove_if(_ctx.words.begin(), _ctx.words.end(),
-            [&set](const Text3dPtr& wp)     { return set.count(wp.get()) > 0; }),
+            [&set](const Text::TokenPtr& wp)     { return set.count(wp.get()) > 0; }),
         _ctx.words.end());
     _dyn_scenery.clear();
 }
@@ -1088,7 +1021,7 @@ GameState::dyn_Apply(const DynArray<DynObject>& objs)
             _ctx.scenery.push_back(std::move(box));
         }
         else {
-            Text3dPtr t = make_Text3d(o.text, Text3d::depth_floor, 0.003f,
+            Text::TokenPtr t = Text::make_Token(o.text, Text::Token::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;
@@ -1153,7 +1086,7 @@ GameState::typing_RebuildPreview(const std::string& s)
     std::string        tok;
 
     while (in >> tok) {
-        Text3dPtr t = make_Text3d(tok, Text3d::depth_floor, 0.003f,
+        Text::TokenPtr t = Text::make_Token(tok, Text::Token::depth_floor, 0.003f,
                                   anchor, ConfigColor::floor);
         t->yaw = static_cast<float>(glyph_yaw);
         t->body.mode = PhysicsMode::Carried;
@@ -1176,7 +1109,7 @@ GameState::typing_ClearPreview()
 
 
 void
-GameState::carry_BeginAnim(Text3dPtr word, bool with_pitch)
+GameState::carry_BeginAnim(Text::TokenPtr word, bool with_pitch)
 {
     WordAnim_Add anim;
     anim.start_pos   = word->pos;
diff --git a/src/states/game_state.hpp b/src/states/game_state.hpp
index 7185ecd..2bdd018 100644
--- a/src/states/game_state.hpp
+++ b/src/states/game_state.hpp
@@ -70,7 +70,7 @@ class GameState : public IState
     GameContext        _ctx;
 
     DynArray<GameObject*>    _dyn_scenery;
-    DynArray<Text3d*>        _typing_words;
+    DynArray<Text::Token::Data*>        _typing_words;
     DynArray<Vec3>           _freelook_return_from;
     DynArray<Projectile>     _projectiles;
     DynArray<Wall::Label>    _wall_labels;
@@ -109,7 +109,7 @@ class GameState : public IState
     void on_WallConnectSubmit();
 
     void carriedline_Update    ();
-    void carry_BeginAnim       (Text3dPtr word, bool with_pitch);
+    void carry_BeginAnim       (Text::TokenPtr word, bool with_pitch);
     void level_TryAdvance      (double dt);
     void typing_ClearPreview   ();
     void typing_RebuildPreview (const std::string& s);
@@ -117,12 +117,11 @@ class GameState : public IState
     void update_WorldSimulation(double dt);
 
     void infowall_Clear           ();
-    void walltext_HandleShootDown (ProjectileResult& result, DynArray<size_t>& to_erase);
 
     void projectile_FeedProgramInput(ProjectileResult&);
     void projectile_LandOnFloor     (ProjectileResult&);
     void projectile_LandOnWall      (ProjectileResult&);
-    void projectile_LandOnWallText  (ProjectileResult&, DynArray<size_t>& wall_text_to_erase);
+    void projectile_LandOnWallText  (ProjectileResult&);
 
     void pult_DispatchAction(PultAction act);
 
diff --git a/src/states/menu_state.cpp b/src/states/menu_state.cpp
index 7e0e3c1..c921d6e 100644
--- a/src/states/menu_state.cpp
+++ b/src/states/menu_state.cpp
@@ -65,7 +65,7 @@ MenuState::MenuState()
     _camera->yaw   = 0;
     _camera->pitch = 0;
 
-    _title = make_Text3d("Runtime Compiler",
+    _title = Text::make_Token("Runtime Compiler",
                         TEXT_DEPTH, TITLE_SCALE,
                         slot_PosForColumn(COLUMN_MID_RAD, TITLE_Y),
                         COLOR_TITLE);
@@ -114,7 +114,7 @@ MenuState::build_Items()
 
     for (size_t i = 0; i < _items.size(); ++i) {
         const auto color = (i == _selected) ? COLOR_SELECTED : COLOR_NORMAL;
-        _items[i].text3d = make_Text3d(_items[i].label,
+        _items[i].text3d = Text::make_Token(_items[i].label,
                                       TEXT_DEPTH, ITEM_SCALE,
                                       _items[i].slot_pos,
                                       color);
diff --git a/src/states/menu_state.hpp b/src/states/menu_state.hpp
index 1050634..70ca567 100644
--- a/src/states/menu_state.hpp
+++ b/src/states/menu_state.hpp
@@ -6,7 +6,7 @@
 #include "entities/camera.hpp"
 #include "entities/world.hpp"
 #include "istate.hpp"
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 #include "utils/globals.hpp"
 #include "utils/light.hpp"
 
@@ -24,7 +24,7 @@ class MenuState : public IState
     {
         ItemKind    kind;
         size_t      level_index = 0;
-        Text3dPtr   text3d;
+        Text::TokenPtr   text3d;
         Vec3        slot_pos {};
         std::string label;
     };
@@ -32,7 +32,7 @@ class MenuState : public IState
     cpputils::unique_ptr<World>  _world;
     cpputils::unique_ptr<Camera> _camera;
     cpputils::unique_ptr<Light>  _light;
-    Text3dPtr                    _title;
+    Text::TokenPtr                    _title;
     size_t _selected      {0};
     double _scroll_current{0.0};
     double _scroll_target {0.0};
diff --git a/src/systems/aim.hpp b/src/systems/aim.hpp
index 2890238..a7adcf2 100644
--- a/src/systems/aim.hpp
+++ b/src/systems/aim.hpp
@@ -6,17 +6,18 @@
 #include "entities/game_object.hpp"
 #include "systems/collision.hpp"
 #include "text/handler.hpp"
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 #include "utils/globals.hpp"
 #include "wall/wall.hpp"
 
+
 namespace Aim
 {
 
 
 struct Result
 {
-    Text3d* track { nullptr };
+    Text::Token::Data* track { nullptr };
 
     Vec3 point {};
     Vec3 local {};
@@ -25,9 +26,8 @@ struct Result
 
 inline Result
 crosshair_Target(const Camera& camera,
-                 const DynArray<Text3dPtr>&     words,
+                 const DynArray<Text::TokenPtr>&     words,
                  const DynArray<GameObjectPtr>& scenery,
-                 const DynArray<Wall::Ptr>&   wall_texts,
                  const DynArray<Text::Handler*>& walls,
                  float world_scale)
 {
@@ -53,9 +53,9 @@ crosshair_Target(const Camera& camera,
     out.point = Vec3{ eye.x + seg.x, eye.y + seg.y, eye.z + seg.z };
 
     double  best_t = 1.0;
-    Text3d* win    = nullptr;
+    Text::Token::Data* win    = nullptr;
 
-    auto test = [&](const Vec3& mn, const Vec3& mx, Text3d* obj) {
+    auto test = [&](const Vec3& mn, const Vec3& mx, Text::Token::Data* obj) {
         Collision::SweptResult sw = Collision::swept_RayAabb(eye, seg, mn, mx);
         if (sw.hit && sw.t < best_t) {
             best_t    = sw.t;
@@ -66,12 +66,10 @@ crosshair_Target(const Camera& camera,
         }
     };
 
-    for (const Text3dPtr& w : words)
+    for (const Text::TokenPtr& w : words)
         if (w) test(w->aabb_Min(), w->aabb_Max(), w.get());
     for (const GameObjectPtr& s : scenery)
         if (s) test(s->aabb_Min(), s->aabb_Max(), nullptr);
-    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)
diff --git a/src/systems/carry_system.cpp b/src/systems/carry_system.cpp
index 2f14312..4642d45 100644
--- a/src/systems/carry_system.cpp
+++ b/src/systems/carry_system.cpp
@@ -10,7 +10,7 @@ CarriedLine::empty() const
 
 
 void
-CarriedLine::push(Text3dPtr w)
+CarriedLine::push(Text::TokenPtr w)
 {
     CarriedWordNodePtr node = cpputils::make_unique<CarriedWordNode>(std::move(w));
     node->prev = last;
@@ -27,7 +27,7 @@ CarriedLine::push(Text3dPtr w)
 }
 
 
-Text3dPtr
+Text::TokenPtr
 CarriedLine::pop_back()
 {
     while (last && !last->word) {
@@ -43,7 +43,7 @@ CarriedLine::pop_back()
     }
     if (!last) return nullptr;
 
-    Text3dPtr word = std::move(last->word);
+    Text::TokenPtr word = std::move(last->word);
 
     if (last->prev) {
         last = last->prev;
@@ -58,12 +58,12 @@ CarriedLine::pop_back()
 }
 
 
-Text3dPtr
+Text::TokenPtr
 CarriedLine::pop_front()
 {
     if (!first) return nullptr;
 
-    Text3dPtr word = std::move(first->word);
+    Text::TokenPtr word = std::move(first->word);
 
     if (first->next) {
         first       = std::move(first->next);
@@ -110,12 +110,12 @@ CarriedLine::clear()
 void
 CarriedLine::compact()
 {
-    DynArray<Text3dPtr> survivors;
+    DynArray<Text::TokenPtr> survivors;
     for (CarriedWordNode* node = first.get(); node; node = node->next.get())
         if (node->word) survivors.push_back(std::move(node->word));
 
     clear();
-    for (Text3dPtr& w : survivors)
+    for (Text::TokenPtr& w : survivors)
         push(std::move(w));
 }
 
@@ -163,12 +163,12 @@ CarriedLine::get_SlotPosition(const Vec3& anchor,
             double a = 0.5, b = 0.5;
 
             if (nodes[i]->word->type == ObjectType::Text)
-                a = static_cast<const Text3d&>(*nodes[i]->word).half_extent;
+                a = static_cast<const Text::Token::Data&>(*nodes[i]->word).half_extent;
             else
                 a = nodes[i]->word->shape.half_extents.x;
 
             if (nodes[i+1]->word->type == ObjectType::Text)
-                b = static_cast<const Text3d&>(*nodes[i+1]->word).half_extent;
+                b = static_cast<const Text::Token::Data&>(*nodes[i+1]->word).half_extent;
             else
                 b = nodes[i+1]->word->shape.half_extents.x;
 
diff --git a/src/systems/carry_system.hpp b/src/systems/carry_system.hpp
index 5ec9995..8c77b79 100644
--- a/src/systems/carry_system.hpp
+++ b/src/systems/carry_system.hpp
@@ -1,16 +1,16 @@
 #pragma once
 
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 #include "utils/globals.hpp"
 
 
 
 struct CarriedWordNode
 {
-    Text3dPtr word;
+    Text::TokenPtr word;
     CarriedWordNode* prev{nullptr};
     cpputils::unique_ptr<CarriedWordNode> next;
-    CarriedWordNode(Text3dPtr w) : word(std::move(w)) {}
+    CarriedWordNode(Text::TokenPtr w) : word(std::move(w)) {}
 };
 
 
@@ -25,9 +25,9 @@ struct CarriedLine
     void compact();
 
 
-    void      push     (Text3dPtr w);
-    Text3dPtr pop_back ();
-    Text3dPtr pop_front();
+    void      push     (Text::TokenPtr w);
+    Text::TokenPtr pop_back ();
+    Text::TokenPtr pop_front();
 
     DynArray<CarriedWordNode*>
               nodes        () const;
diff --git a/src/systems/celebration_system.cpp b/src/systems/celebration_system.cpp
index ece95f4..2e65e25 100644
--- a/src/systems/celebration_system.cpp
+++ b/src/systems/celebration_system.cpp
@@ -29,8 +29,8 @@ CelebrationSystem::start(const Camera& cam, double& glyph_yaw)
     banner_pos.z = clamp(banner_pos.z, -ws + margin, ws - margin);
     banner_pos.y = clamp(banner_pos.y, floor + 1.0,  ws - margin);
 
-    _banner = make_Text3d("Success",
-                          Text3d::depth_floor * 2.0f,
+    _banner = Text::make_Token("Success",
+                          Text::Token::depth_floor * 2.0f,
                           0.012f, banner_pos,
                           ConfigColor::white);
     _banner->yaw       = static_cast<float>(glyph_yaw);
diff --git a/src/systems/celebration_system.hpp b/src/systems/celebration_system.hpp
index 9a24e9a..99f2f72 100644
--- a/src/systems/celebration_system.hpp
+++ b/src/systems/celebration_system.hpp
@@ -1,6 +1,7 @@
 #pragma once
 
 #include "utils/globals.hpp"
+#include "text/token.hpp"
 
 class  GlRenderer;
 class  ParticleSystem;
@@ -14,7 +15,7 @@ class CelebrationSystem
     double    _t          = 0,
               _duration   = 3.5,
               _next_burst = 0;
-    Text3dPtr _banner;
+    Text::TokenPtr _banner;
 public:
     void   start   (const Camera& cam, double& glyph_yaw);
     void   tick    (double dt, ParticleSystem& particle_sys);
diff --git a/src/systems/compiler_system.cpp b/src/systems/compiler_system.cpp
index a520713..9bedaa1 100644
--- a/src/systems/compiler_system.cpp
+++ b/src/systems/compiler_system.cpp
@@ -1,7 +1,7 @@
 #include <SDL3/SDL.h>
 
 #include "systems/compiler_system.hpp"
-#include "utils/text.hpp"
+#include "text/text.hpp"
 
 
 CompilerSystem::~CompilerSystem()
diff --git a/src/systems/cpp_highlight_system.hpp b/src/systems/cpp_highlight_system.hpp
index f7a8f89..bbfee6a 100644
--- a/src/systems/cpp_highlight_system.hpp
+++ b/src/systems/cpp_highlight_system.hpp
@@ -5,7 +5,7 @@
 #include <string>
 #include <thread>
 
-#include "cpp_token/cpp_highlight.hpp"
+#include "text/text.hpp"
 #include "utils/globals.hpp"
 
 
@@ -24,7 +24,7 @@ public:
         spawn(source, gen);
     }
 
-    bool poll(DynArray<HiSpan>& out, uint64_t& out_gen)
+    bool poll(DynArray<Text::HiSpan>& out, uint64_t& out_gen)
     {
         if (!_done.load()) return false;
 
@@ -54,7 +54,7 @@ private:
     std::thread       _worker;
     std::mutex        _mtx;
     std::string       _last_requested;
-    DynArray<HiSpan>  _pending;
+    DynArray<Text::HiSpan>  _pending;
 
     void spawn(const std::string& source, uint64_t gen)
     {
@@ -63,7 +63,7 @@ private:
         _done.store(false);
         _last_gen_pending = gen;
         _worker = std::thread([this, source, gen]() {
-            DynArray<HiSpan> spans = CppHighlight::classify(source);
+            DynArray<Text::HiSpan> spans = Text::CppHighlight::classify(source);
             {
                 std::lock_guard<std::mutex> lk(_mtx);
                 _pending    = std::move(spans);
diff --git a/src/systems/dyn_loader_system.cpp b/src/systems/dyn_loader_system.cpp
index f9fb0c7..e429323 100644
--- a/src/systems/dyn_loader_system.cpp
+++ b/src/systems/dyn_loader_system.cpp
@@ -4,7 +4,7 @@
 #include <sys/select.h>
 
 #include "systems/dyn_loader_system.hpp"
-#include "utils/text.hpp"
+#include "text/text.hpp"
 
 
 // Where game_mod_abi.h is installed at runtime, and the build scratch dir.
diff --git a/src/systems/game_mod_abi.h b/src/systems/game_mod_abi.h
index 3f5be47..72ed3fc 100644
--- a/src/systems/game_mod_abi.h
+++ b/src/systems/game_mod_abi.h
@@ -25,7 +25,7 @@ typedef enum {
 } DynObjKind;
 
 /* A flat, POD description of one object to spawn. No pointers, no C++:
- * the library fills these in, the engine constructs the real Text3d /
+ * the library fills these in, the engine constructs the real Text::Token::Data /
  * SolidBox from them. Nothing the library allocates outlives dlclose(). */
 typedef struct {
     int    kind;          /* DynObjKind */
diff --git a/src/systems/hint_system.cpp b/src/systems/hint_system.cpp
index 27e1b9e..196b4dc 100644
--- a/src/systems/hint_system.cpp
+++ b/src/systems/hint_system.cpp
@@ -7,7 +7,7 @@ HintSystem::HintSystem()
 {
     _label.set_Centre    (Vec3{ 0.0, World::get_FloorY<double>() + 2.5, 0.0 });
     _label.set_Billboard (true);
-    _label.set_Style     (ConfigColor::yellow, Text3d::depth_wall, 0.003f);
+    _label.set_Style     (ConfigColor::yellow, Text::Token::depth_wall, 0.003f);
     _label.set_Collidable(true);
 }
 
diff --git a/src/systems/physics_system.cpp b/src/systems/physics_system.cpp
index d396f0b..2179d59 100644
--- a/src/systems/physics_system.cpp
+++ b/src/systems/physics_system.cpp
@@ -5,7 +5,7 @@
 #include "systems/collision.hpp"
 #include "systems/physics_system.hpp"
 #include "systems/projectile_system.hpp"
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 #include "utils/config.hpp"
 #include "utils/frame_profiler.hpp"
 #include "wall/label.hpp"
@@ -265,22 +265,22 @@ PhysicsSystem::resolve_WorldBounds(GameObject& obj)
 
 void
 PhysicsSystem::update_Projectiles(DynArray<Projectile>& projectiles, double dt,
-                                  const DynArray<Text3dPtr>& words)
+                                  const DynArray<Text::TokenPtr>& words)
 {
     for (Projectile& p : projectiles) {
         if (p.sticking)      continue;
         if (p.words.empty()) continue;
 
         p.step_dt       = dt;
-        p.prev_centroid = Text3d::words_centroid(p.words);
+        p.prev_centroid = Text::Token::Data::words_centroid(p.words);
 
         if (p.track_obj) {
             bool alive = false;
-            for (const Text3dPtr& w : words)
+            for (const Text::TokenPtr& w : words)
                 if (w.get() == p.track_obj) { alive = true; break; }
 
             if (alive) {
-                Text3d* o  = p.track_obj;
+                Text::Token::Data* o  = p.track_obj;
                 const double cy = SDL_cos(o->yaw), sy = SDL_sin(o->yaw);
                 const Vec3 live {
                     o->pos.x + cy * p.track_local.x + sy * p.track_local.z,
@@ -307,7 +307,7 @@ PhysicsSystem::update_Projectiles(DynArray<Projectile>& projectiles, double dt,
             p.vel.y -= ConfigPhysics::proj_grav * dt;
         }
 
-        for (Text3dPtr& w : p.words) {
+        for (Text::TokenPtr& w : p.words) {
             if (!w) continue;
             w->pos.x += p.vel.x * dt;
             w->pos.y += p.vel.y * dt;
@@ -354,7 +354,7 @@ PhysicsSystem::resolve_SingleBoxCollision(
     double push_x = contact.x - cur.x;
     double push_y = contact.y - cur.y;
     double push_z = contact.z - cur.z;
-    for (const Text3dPtr& gw : proj.words) {
+    for (const Text::TokenPtr& gw : proj.words) {
         if (!gw) continue;
         gw->pos.x += push_x;
         gw->pos.y += push_y;
@@ -380,12 +380,12 @@ PhysicsSystem::resolve_Projectiles(DynArray<Projectile>& projectiles,
     for (Projectile& proj : projectiles) {
         if (proj.sticking || proj.words.empty()) continue;
 
-        Vec3 cur  = Text3d::words_centroid(proj.words);
+        Vec3 cur  = Text::Token::Data::words_centroid(proj.words);
         Vec3 prev = proj.prev_centroid;
         Vec3 dir  { cur.x - prev.x, cur.y - prev.y, cur.z - prev.z };
 
         Vec3 word_he { 0.3, 0.07, 0.07 };
-        for (const Text3dPtr& w : proj.words) {
+        for (const Text::TokenPtr& w : proj.words) {
             if (w) { word_he = w->shape.half_extents; break; }
         }
 
@@ -441,7 +441,7 @@ PhysicsSystem::resolve_Projectiles(DynArray<Projectile>& projectiles,
 
                 const auto& lw = label->get_Words();
                 for (size_t i = 0; i < lw.size(); ++i) {
-                    const Text3dPtr& hw = lw[i];
+                    const Text::TokenPtr& hw = lw[i];
                     if (!hw || !hw->loaded) continue;
 
                     Vec3 vel_before = proj.vel;
diff --git a/src/systems/physics_system.hpp b/src/systems/physics_system.hpp
index a5c426c..ccdd35f 100644
--- a/src/systems/physics_system.hpp
+++ b/src/systems/physics_system.hpp
@@ -51,7 +51,7 @@ public:
     void update_Objects(const DynArray<GameObject*>& objects,
                         double dt, double gravity);
     void update_Projectiles (DynArray<Projectile>& projectiles, double dt,
-                             const DynArray<Text3dPtr>& words);
+                             const DynArray<Text::TokenPtr>& words);
     void resolve_Projectiles(DynArray<Projectile>& projectiles,
                              const DynArray<GameObject*>* statics,
                              const DynArray<Text::Handler*>* labels,
diff --git a/src/systems/pick_system.cpp b/src/systems/pick_system.cpp
index 761105d..7db92b7 100644
--- a/src/systems/pick_system.cpp
+++ b/src/systems/pick_system.cpp
@@ -14,7 +14,7 @@ ang_score(const GameObject& obj, const Vec3& eye, const Vec3& fwd)
 {
     double he;
     if (obj.type == ObjectType::Text)
-        he = static_cast<double>(static_cast<const Text3d&>(obj).half_extent);
+        he = static_cast<double>(static_cast<const Text::Token::Data&>(obj).half_extent);
     else
         he = obj.shape.half_extents.x;
 
@@ -58,9 +58,8 @@ ang_score(const GameObject& obj, const Vec3& eye, const Vec3& fwd)
 
 PickResult
 pick(const Camera& camera,
-     const DynArray<Text3dPtr>&      words,
+     const DynArray<Text::TokenPtr>&      words,
      const DynArray<GameObjectPtr>&  scenery,
-     const DynArray<Wall::Ptr>&      wall_texts,
      const DynArray<Text::Handler*>& walls)
 {
     const Vec3&  eye     = camera.pos;
@@ -70,9 +69,9 @@ pick(const Camera& camera,
     double best_angle = MAX_ANGLE;
 
     for (size_t i = 0; i < words.size(); ++i) {
-        const Text3dPtr& w = words[i];
+        const Text::TokenPtr& w = words[i];
         if (!w || !w->interactable) continue;
-        if (w->ai.state != Text3dAI::State::Idle) continue;
+        if (w->ai.state != Text::Token::Ai::State::Idle) continue;
         if (w->pos.y < floor_y)     continue;
 
         double a = ang_score(*w, eye, camera.fwd);
@@ -94,23 +93,11 @@ pick(const Camera& camera,
         }
     }
 
-    for (size_t i = 0; i < wall_texts.size(); ++i) {
-        const Wall::Ptr& wt = wall_texts[i];
-        if (!wt || !wt->text3d)          continue;
-        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;
-            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();
+                Text::Token::Data* t = tp.get();
                 if (!t || !t->interactable) continue;
                 if (t->pos.y < floor_y)     continue;
                 double a = ang_score(*t, eye, camera.fwd);
diff --git a/src/systems/pick_system.hpp b/src/systems/pick_system.hpp
index 5a94f0c..09063a1 100644
--- a/src/systems/pick_system.hpp
+++ b/src/systems/pick_system.hpp
@@ -3,40 +3,27 @@
 #include "entities/camera.hpp"
 #include "entities/game_object.hpp"
 #include "text/handler.hpp"
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 #include "utils/globals.hpp"
 #include "wall/wall.hpp"
 
 
-enum class PickKind : uint8_t
-{
-    None,
-    Scenery,
-    Wall,
-    Word,
-    WallWord,
-};
-
+enum class PickKind : uint8_t { None, Scenery, Word, WallWord };
 
 struct PickResult
 {
     size_t word_index { 0 };
-    size_t wall_index { 0 };
-
     GameObject*    scenery_obj  { nullptr };
     Text::Handler* wall_handler { nullptr };
-    Text::Token*   wall_token   { nullptr };
+    Text::Token::Data*   wall_token   { nullptr };
     PickKind       kind         { PickKind::None };
-
     bool hit() const { return kind != PickKind::None; }
 };
 
-
 namespace PickSystem
 {
     PickResult pick(const Camera& camera,
-                    const DynArray<Text3dPtr>&        words,
-                    const DynArray<GameObjectPtr>&    scenery,
-                    const DynArray<Wall::Ptr>&        wall_texts,
-                    const DynArray<Text::Handler*>&   walls);
+                    const DynArray<Text::TokenPtr>& words,
+                    const DynArray<GameObjectPtr>&  scenery,
+                    const DynArray<Text::Handler*>& walls);
 }
diff --git a/src/systems/program_system.cpp b/src/systems/program_system.cpp
index c26b0ff..f1bbd02 100644
--- a/src/systems/program_system.cpp
+++ b/src/systems/program_system.cpp
@@ -8,7 +8,7 @@
 
 #include "entities/world.hpp"
 #include "systems/program_system.hpp"
-#include "utils/text.hpp"
+#include "text/text.hpp"
 #include "utils/config.hpp"
 
 
@@ -166,14 +166,14 @@ ProgramSystem::echo_Input(Text::Handler& out, const std::string& line)
 
 
 void
-ProgramSystem::echo_Words(Text::Handler& out, DynArray<Text3dPtr>& words)
+ProgramSystem::echo_Words(Text::Handler& out, DynArray<Text::TokenPtr>& words)
 {
     if (words.empty()) return;
 
     // 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) {
+    for (const Text::TokenPtr& w : words) {
         if (!w || w->type != ObjectType::Text) continue;
         if (!line.empty()) line += ' ';
         line += w->text;
diff --git a/src/systems/program_system.hpp b/src/systems/program_system.hpp
index 30a17ee..ad40e26 100644
--- a/src/systems/program_system.hpp
+++ b/src/systems/program_system.hpp
@@ -30,7 +30,7 @@ public:
 
     void feed_Input (const std::string& text);
     void echo_Input (Text::Handler& out, const std::string& line);
-    void echo_Words (Text::Handler& out, DynArray<Text3dPtr>& words);
+    void echo_Words (Text::Handler& out, DynArray<Text::TokenPtr>& words);
 
     bool is_Running () const { return _child > 0; }
 
diff --git a/src/systems/projectile_system.cpp b/src/systems/projectile_system.cpp
index f4daef9..8f0779a 100644
--- a/src/systems/projectile_system.cpp
+++ b/src/systems/projectile_system.cpp
@@ -83,7 +83,7 @@ tick_Sticking(Projectile& p, double dt, double ws,
     const float  fy    = wall_target_yaw(p.stick_wall);
     const double plane = wall_plane_coord(p.stick_wall, ws);
 
-    for (Text3dPtr& w : p.words) {
+    for (Text::TokenPtr& w : p.words) {
         if (!w || w->type != ObjectType::Text) continue;
 
         Vec3 target = wall_reconstruct(p.stick_wall, plane,
@@ -111,7 +111,7 @@ check_FloorCeiling(Projectile& p, const Vec3& centroid, double ws,
                    DynArray<ProjectileResult>& results)
 {
     double word_he_y = 0.07;
-    for (const Text3dPtr& w : p.words) {
+    for (const Text::TokenPtr& w : p.words) {
         if (w) { word_he_y = w->shape.half_extents.y; break; }
     }
 
@@ -122,7 +122,7 @@ check_FloorCeiling(Projectile& p, const Vec3& centroid, double ws,
 
     if (centroid.y > ws) {
         p.vel.y = bounce_velocity(p.vel.y, 0.75);
-        for (Text3dPtr& w : p.words)
+        for (Text::TokenPtr& w : p.words)
             if (w) w->pos.y = ws - 0.01;
     }
     return false;
@@ -171,7 +171,7 @@ check_WallTextIntercept(const Projectile& p, const Vec3& centroid, double dt,
     const Vec3 ray_dir = next_centroid - p.prev_centroid;
 
     Vec3 word_he { 0.3, 0.07, 0.07 };
-    for (const Text3dPtr& w : p.words) {
+    for (const Text::TokenPtr& w : p.words) {
         if (w) { word_he = w->shape.half_extents; break; }
     }
 
@@ -233,7 +233,7 @@ begin_EditorStick(Projectile& p, Wall::Position hit_pos)
     const double dot  = cr_x * wt_x + cr_z * wt_z;
     const double sign = (dot >= 0.0) ? 1.0 : -1.0;
 
-    const Vec3   c_pos  = Text3d::words_centroid(p.words);
+    const Vec3   c_pos  = Text::Token::Data::words_centroid(p.words);
     const double plane  = wall_plane_coord(hit_pos, World::get_BoxScale());
     const bool   x_wall = (hit_pos == Wall::Position::NegX || hit_pos == Wall::Position::PosX);
 
@@ -254,7 +254,7 @@ begin_EditorStick(Projectile& p, Wall::Position hit_pos)
     const double c_tan = wall_tangent(hit_pos, aim.x, aim.z);
     const double c_y   = aim.y;
 
-    for (Text3dPtr& w : p.words) {
+    for (Text::TokenPtr& w : p.words) {
         if (!w || w->type != ObjectType::Text) continue;
         w->ribbon_local = {
             c_tan + w->ribbon_local.x * sign,
@@ -284,7 +284,7 @@ begin_ProgramStick(Projectile& p, double ws)
     const double c_tan = 0.0;
     const double c_y   = -ws + 1.0;
 
-    for (Text3dPtr& w : p.words) {
+    for (Text::TokenPtr& w : p.words) {
         if (!w || w->type != ObjectType::Text) continue;
         w->ribbon_local = {
             c_tan + w->ribbon_local.x * sign,
@@ -313,14 +313,14 @@ bounce_OffWall(Projectile& p, Wall::Position hit_pos, double ws,
 
     if (x_axis) {
         p.vel.x = bounce_velocity(p.vel.x, 0.70);
-        for (Text3dPtr& w : p.words) if (w)
+        for (Text::TokenPtr& w : p.words) if (w)
             w->pos.x = (hit_pos == Wall::Position::PosX)
                      ? ( ws - ConfigPhysics::stick_dist - 0.05)
                      : (-ws + ConfigPhysics::stick_dist + 0.05);
     }
     else {
         p.vel.z = bounce_velocity(p.vel.z, 0.70);
-        for (Text3dPtr& w : p.words) if (w)
+        for (Text::TokenPtr& w : p.words) if (w)
             w->pos.z = (hit_pos == Wall::Position::PosZ)
                      ? ( ws - ConfigPhysics::stick_dist - 0.05)
                      : (-ws + ConfigPhysics::stick_dist + 0.05);
@@ -332,7 +332,7 @@ bounce_OffWall(Projectile& p, Wall::Position hit_pos, double ws,
 static double
 editor_AimY(const Projectile& p, double plane_x)
 {
-    if (SDL_fabs(p.dir.x) <= 1e-6) return Text3d::words_centroid(p.words).y;
+    if (SDL_fabs(p.dir.x) <= 1e-6) return Text::Token::Data::words_centroid(p.words).y;
     const double t = (plane_x - p.cam_origin.x) / p.dir.x;
     return p.cam_origin.y + p.dir.y * t;
 }
@@ -340,7 +340,7 @@ editor_AimY(const Projectile& p, double plane_x)
 static double
 editor_AimZ(const Projectile& p, double plane_x)
 {
-    if (SDL_fabs(p.dir.x) <= 1e-6) return Text3d::words_centroid(p.words).z;
+    if (SDL_fabs(p.dir.x) <= 1e-6) return Text::Token::Data::words_centroid(p.words).z;
     const double t = (plane_x - p.cam_origin.x) / p.dir.x;
     return p.cam_origin.z + p.dir.z * t;
 }
@@ -388,7 +388,7 @@ handle_WallHit(Projectile& p, const Vec3& centroid, Wall::Position hit_pos,
 
 void
 ProjectileSystem::launch(DynArray<Projectile>&   out,
-                         DynArray<Text3dPtr> words,
+                         DynArray<Text::TokenPtr> words,
                          const Vec3& fwd,
                          const Vec3& eye,
                          const Aim::Result& aim)
@@ -397,10 +397,10 @@ ProjectileSystem::launch(DynArray<Projectile>&   out,
 
     double yaw = SDL_atan2(-fwd.x, -fwd.z);
 
-    for (Text3dPtr& w : words) {
+    for (Text::TokenPtr& w : words) {
         if (!w) continue;
         if (w->type == ObjectType::Text)
-            w->set_Look(ConfigColor::keep, Text3d::depth_floor);
+            w->set_Look(ConfigColor::keep, Text::Token::depth_floor);
 
         w->yaw = static_cast<float>(yaw);
     }
@@ -408,11 +408,11 @@ ProjectileSystem::launch(DynArray<Projectile>&   out,
     double right_x =  SDL_cos(yaw);
     double right_z = -SDL_sin(yaw);
 
-    Vec3 centroid = Text3d::words_centroid(words);
+    Vec3 centroid = Text::Token::Data::words_centroid(words);
 
-    for (Text3dPtr& w : words) {
+    for (Text::TokenPtr& w : words) {
         if (!w || w->type != ObjectType::Text) continue;
-        Text3d& tx = static_cast<Text3d&>(*w);
+        Text::Token::Data& tx = static_cast<Text::Token::Data&>(*w);
         double dx = w->pos.x - centroid.x;
         double dz = w->pos.z - centroid.z;
         tx.ribbon_local = {
@@ -473,7 +473,7 @@ ProjectileSystem::update(DynArray<Projectile>&        projectiles,
 
         if (p.words.empty()) { it = projectiles.erase(it); continue; }
 
-                const Vec3 centroid = Text3d::words_centroid(p.words);
+                const Vec3 centroid = Text::Token::Data::words_centroid(p.words);
         bool remove = false;
 
         if (check_FloorCeiling(p, centroid, ws, results)) remove = true;
diff --git a/src/systems/projectile_system.hpp b/src/systems/projectile_system.hpp
index f1655c1..4351c95 100644
--- a/src/systems/projectile_system.hpp
+++ b/src/systems/projectile_system.hpp
@@ -1,7 +1,7 @@
 #pragma once
 
 #include "systems/aim.hpp"
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 #include "utils/globals.hpp"
 #include "wall/wall.hpp"
 
@@ -34,7 +34,7 @@ struct Projectile
     double stick_t = 0.0;
     double step_dt = 0.0;
 
-    Text3d* track_obj = nullptr;
+    Text::Token::Data* track_obj = nullptr;
 
     Vec3 vel{};
     Vec3 dir{};
@@ -50,7 +50,7 @@ struct Projectile
     Vec3 ribbon_start_origin {};
     Vec3 ribbon_target_origin{};
 
-    DynArray<Text3dPtr> words;
+    DynArray<Text::TokenPtr> words;
 };
 
 
@@ -73,14 +73,14 @@ struct ProjectileResult
     Vec3   vel             {};
 
     ProjectileLanding   landing = ProjectileLanding::None;
-    DynArray<Text3dPtr> words;
+    DynArray<Text::TokenPtr> words;
 };
 
 
 struct ProjectileSystem
 {
     void launch(DynArray<Projectile>& out,
-                DynArray<Text3dPtr> words,
+                DynArray<Text::TokenPtr> words,
                 const Vec3& fwd,
                 const Vec3& eye,
                 const Aim::Result& aim);
diff --git a/src/systems/wall_connection.cpp b/src/systems/wall_connection.cpp
index f53da0c..92c3bd0 100644
--- a/src/systems/wall_connection.cpp
+++ b/src/systems/wall_connection.cpp
@@ -28,7 +28,7 @@ Connection::begin_Program(const Vec3& pult_pos)
 }
 
 void
-Connection::begin_Editor(const Vec3& pult_pos, const ::Text::Handler& editor)
+Connection::begin_Editor(const Vec3& pult_pos, const Text::Handler& editor)
 {
     _target   = Target::Editor;
     _pult_pos = pult_pos;
@@ -70,7 +70,7 @@ Connection::rebuild_ProgramPreview(const std::string& buffer)
     Vec3 pos = tf.position;
     pos.y    = ProgramSystem::start_y;
 
-    _prog_input      = make_Text3d("> " + buffer, Text3d::depth_wall, 0.003f, pos, ConfigColor::white);
+    _prog_input      = Text::make_Token("> " + buffer, Text::Token::depth_wall, 0.003f, pos, ConfigColor::white);
     _prog_input->yaw = tf.yaw;
 }
 
@@ -82,7 +82,7 @@ Connection::rebuild_Cmdline(const std::string& text)
     if (text.empty()) return;
 
     // Built at origin; render() places it at the eased _cmd_pos each frame.
-    _cmdline = make_Text3d(text, Text3d::depth_wall, 0.0016f, Vec3{0,0,0}, ConfigColor::white);
+    _cmdline = Text::make_Token(text, Text::Token::depth_wall, 0.0016f, Vec3{0,0,0}, ConfigColor::white);
 }
 
 
@@ -190,7 +190,7 @@ Connection::draw_EditorCursor(GlRenderer& gl, const Camera& cam, const Light& li
     if (!_editor) return;
     if (SDL_fmod(_blink, 0.8) > 0.4) return;
 
-    const ::Text::Handler::MonoMetrics m = _editor->mono_Metrics();
+    const Text::Handler::MonoMetrics m = _editor->mono_Metrics();
     if (m.char_w <= 0.0) return;                 // no mono layout yet
 
     const int    row   = _editor->cursor_Row();
diff --git a/src/systems/wall_connection.hpp b/src/systems/wall_connection.hpp
index 44fafbe..075c434 100644
--- a/src/systems/wall_connection.hpp
+++ b/src/systems/wall_connection.hpp
@@ -2,7 +2,7 @@
 
 #include <string>
 
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 
 
 class Camera;
@@ -14,10 +14,6 @@ namespace Text { class Handler; }
 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:
@@ -34,7 +30,7 @@ public:
     void    clear_ProgramPreview();
 
     void    begin_Program(const Vec3& pult_pos);
-    void    begin_Editor (const Vec3& pult_pos, const ::Text::Handler& editor);
+    void    begin_Editor (const Vec3& pult_pos, const Text::Handler& editor);
 
 
 private:
@@ -44,10 +40,10 @@ private:
 
     Target _target { Target::None };
 
-    const ::Text::Handler* _editor { nullptr };   // editor model (Editor target only)
+    const Text::Handler* _editor { nullptr };
 
-    Text3dPtr _cmdline;
-    Text3dPtr _prog_input;
+    Text::TokenPtr _cmdline;
+    Text::TokenPtr _prog_input;
 
     Vec3 _cmd_pos {};
     Vec3 _pult_pos{};
diff --git a/src/systems/word_ai_system.cpp b/src/systems/word_ai_system.cpp
index 1ec2a9f..8574e9c 100644
--- a/src/systems/word_ai_system.cpp
+++ b/src/systems/word_ai_system.cpp
@@ -1,12 +1,12 @@
 #include <SDL3/SDL.h>
 #include <cstdlib>
 
-#include "cpp_token/token_color.hpp"
 #include "entities/player.hpp"
 #include "entities/world.hpp"
 #include "systems/carry_system.hpp"
 #include "systems/collision.hpp"
 #include "systems/cpp_highlight_system.hpp"
+#include "text/token.hpp"
 #include "utils/config.hpp"
 #include "word_ai_system.hpp"
 
@@ -20,61 +20,61 @@ perpendicular_horizontal(const Vec3& v)
 
 
 void
-WordAiSystem::update(DynArray<Text3dPtr>& words,
+WordAiSystem::update(DynArray<Text::TokenPtr>& words,
                      const Player& player,
                      CarriedLine& carried,
-                     DynArray<Text3dPtr>& dropped,
+                     DynArray<Text::TokenPtr>& dropped,
                      double dt)
 {
-    DynArray<Text3dPtr> released;
+    DynArray<Text::TokenPtr> released;
 
     int active_ai_count = 0;
-    for (const Text3dPtr& w : words) {
+    for (const Text::TokenPtr& w : words) {
         if (!w) continue;
-        if (w->ai.state != Text3dAI::State::Idle) ++active_ai_count;
+        if (w->ai.state != Text::Token::Ai::State::Idle) ++active_ai_count;
     }
 
     const size_t n = words.size();
     for (size_t i = 0; i < n; ++i) {
-        Text3dPtr& w = words[i];
+        Text::TokenPtr& w = words[i];
         if (!w) continue;
 
-        if ( w->ai.state  != Text3dAI::State::Idle &&
+        if ( w->ai.state  != Text::Token::Ai::State::Idle &&
              w->body.mode != PhysicsMode::AiHover &&
              w->body.mode != PhysicsMode::Hover) {
-            for (Text3dPtr& s : w->ai.stolen) {
+            for (Text::TokenPtr& s : w->ai.stolen) {
                 if (!s) continue;
                 s->body.mode = PhysicsMode::Dynamic;
                 s->body.vel  = w->body.vel * 0.5;
-                s->set_Color(TokenColor::get(CppHighlight::classify_Word(s->text)));
+                s->set_Color(Text::Token::get_Color(Text::CppHighlight::classify_Word(s->text)));
                 released.push_back(std::move(s));
             }
             w->ai.stolen.clear();
-            w->ai.state = Text3dAI::State::Idle;
-            w->set_Color(TokenColor::get(CppHighlight::classify_Word(w->text)));
+            w->ai.state = Text::Token::Ai::State::Idle;
+            w->set_Color(Text::Token::get_Color(Text::CppHighlight::classify_Word(w->text)));
             continue;
         }
 
         w->ai.state_timer += dt;
 
         switch (w->ai.state) {
-            case Text3dAI::State::Idle:    update_Idle   (*w, player, active_ai_count, dt); break;
-            case Text3dAI::State::Attack:  update_Attack (*w, player, carried, words, dropped, released, dt);
+            case Text::Token::Ai::State::Idle:    update_Idle   (*w, player, active_ai_count, dt); break;
+            case Text::Token::Ai::State::Attack:  update_Attack (*w, player, carried, words, dropped, released, dt);
                                                                                             break;
-            case Text3dAI::State::Flee:    update_Flee   (*w, player, released, dt);        break;
-            case Text3dAI::State::Stunned: update_Stunned(*w, released, dt);                break;
+            case Text::Token::Ai::State::Flee:    update_Flee   (*w, player, released, dt);        break;
+            case Text::Token::Ai::State::Stunned: update_Stunned(*w, released, dt);                break;
         }
 
         update_StolenTrail(*w, dt);
     }
 
-    for (Text3dPtr& r : released)
+    for (Text::TokenPtr& r : released)
         if (r) words.push_back(std::move(r));
 }
 
 
 void
-WordAiSystem::update_Idle(Text3d& w, const Player& p, int active_ai_count, double dt)
+WordAiSystem::update_Idle(Text::Token::Data& w, const Player& p, int active_ai_count, double dt)
 
 {
     if (w.body.mode != PhysicsMode::Hover) return;
@@ -95,10 +95,10 @@ WordAiSystem::update_Idle(Text3d& w, const Player& p, int active_ai_count, doubl
 }
 
 
-void WordAiSystem::update_Attack(Text3d& w, const Player& p, CarriedLine& carried,
-                                 DynArray<Text3dPtr>& all_words,
-                                 DynArray<Text3dPtr>& dropped,
-                                 DynArray<Text3dPtr>& released,
+void WordAiSystem::update_Attack(Text::Token::Data& w, const Player& p, CarriedLine& carried,
+                                 DynArray<Text::TokenPtr>& all_words,
+                                 DynArray<Text::TokenPtr>& dropped,
+                                 DynArray<Text::TokenPtr>& released,
                                  double dt)
 {
     const bool has_carry = (!carried.empty() && carried.last && carried.last->word);
@@ -158,7 +158,7 @@ void WordAiSystem::update_Attack(Text3d& w, const Player& p, CarriedLine& carrie
                 if (!Collision::aabb_Overlap(w, *node->word)) continue;
 
                 if (Random::unit() < ConfigAi::steal_probability) {
-                    Text3dPtr stolen = std::move(node->word);
+                    Text::TokenPtr stolen = std::move(node->word);
                     stolen->body.mode = PhysicsMode::AiHover;
                     stolen->set_Color(ConfigColor::attack);
                     w.ai.stolen.push_back(std::move(stolen));
@@ -166,7 +166,7 @@ void WordAiSystem::update_Attack(Text3d& w, const Player& p, CarriedLine& carrie
                     carried.compact();
                     break;
                 }  else {
-                    Text3dPtr knocked = std::move(node->word);
+                    Text::TokenPtr knocked = std::move(node->word);
                     knocked->body.mode = PhysicsMode::Dynamic;
                     knocked->body.vel  = { 0.0, 2.0, 0.0 };
                     dropped.push_back(std::move(knocked));
@@ -178,10 +178,10 @@ void WordAiSystem::update_Attack(Text3d& w, const Player& p, CarriedLine& carrie
         }
     }
 
-    for (Text3dPtr& other : all_words) {
+    for (Text::TokenPtr& other : all_words) {
         if (!other)                                  continue;
         if (other.get() == &w)                       continue;
-        if (other->ai.state != Text3dAI::State::Idle)continue;
+        if (other->ai.state != Text::Token::Ai::State::Idle)continue;
         if (other->body.mode != PhysicsMode::Hover)  continue;
         if (!Collision::aabb_Overlap(w, *other))     continue;
 
@@ -207,7 +207,7 @@ void WordAiSystem::update_Attack(Text3d& w, const Player& p, CarriedLine& carrie
 
 
 void
-WordAiSystem::update_Flee(Text3d& w, const Player& p, DynArray<Text3dPtr>& released, double dt)
+WordAiSystem::update_Flee(Text::Token::Data& w, const Player& p, DynArray<Text::TokenPtr>& released, double dt)
 {
     Vec3 away = w.pos - p.pos;
     double dist = Math::length(away);
@@ -252,7 +252,7 @@ WordAiSystem::update_Flee(Text3d& w, const Player& p, DynArray<Text3dPtr>& relea
 
 
 void
-WordAiSystem::update_Stunned(Text3d& w, DynArray<Text3dPtr>& released, double dt)
+WordAiSystem::update_Stunned(Text::Token::Data& w, DynArray<Text::TokenPtr>& released, double dt)
 {
     apply_Locomotion(w, Vec3{}, dt);
 
@@ -263,13 +263,13 @@ WordAiSystem::update_Stunned(Text3d& w, DynArray<Text3dPtr>& released, double dt
 }
 
 void
-WordAiSystem::release_StolenWords(Text3d& w, DynArray<Text3dPtr>& out)
+WordAiSystem::release_StolenWords(Text::Token::Data& w, DynArray<Text::TokenPtr>& out)
 {
-    for (Text3dPtr& s : w.ai.stolen) {
+    for (Text::TokenPtr& s : w.ai.stolen) {
         if (!s) continue;
         s->body.mode = PhysicsMode::Dynamic;
         s->body.vel  = { w.body.vel.x * 0.5, 2.0, w.body.vel.z * 0.5 };
-        s->set_Color(TokenColor::get(CppHighlight::classify_Word(s->text)));
+        s->set_Color(Text::Token::get_Color(Text::CppHighlight::classify_Word(s->text)));
         out.push_back(std::move(s));
     }
     w.ai.stolen.clear();
@@ -277,11 +277,11 @@ WordAiSystem::release_StolenWords(Text3d& w, DynArray<Text3dPtr>& out)
 
 
 void
-WordAiSystem::enter_Attack(Text3d& w)
+WordAiSystem::enter_Attack(Text::Token::Data& w)
 {
     w.set_Color(ConfigColor::attack);
     w.body.mode      = PhysicsMode::AiHover;
-    w.ai.state       = Text3dAI::State::Attack;
+    w.ai.state       = Text::Token::Ai::State::Attack;
     w.ai.state_timer = 0.0;
     w.ai.phase_timer = 0.5 + Random::unit() * 1.5;
     w.ai.in_pause    = false;
@@ -294,11 +294,11 @@ WordAiSystem::enter_Attack(Text3d& w)
 
 
 void
-WordAiSystem::enter_Flee(Text3d& w)
+WordAiSystem::enter_Flee(Text::Token::Data& w)
 {
     w.set_Color(ConfigColor::flee);
     w.body.mode      = PhysicsMode::AiHover;
-    w.ai.state       = Text3dAI::State::Flee;
+    w.ai.state       = Text::Token::Ai::State::Flee;
     w.ai.state_timer = 0.0;
     w.ai.phase_timer = 0.3 + Random::unit() * 0.7;
     w.ai.in_pause    = false;
@@ -307,9 +307,9 @@ WordAiSystem::enter_Flee(Text3d& w)
 
 
 void
-WordAiSystem::enter_Stunned(Text3d& w)
+WordAiSystem::enter_Stunned(Text::Token::Data& w)
 {
-    w.ai.state       = Text3dAI::State::Stunned;
+    w.ai.state       = Text::Token::Ai::State::Stunned;
     w.ai.state_timer = 0.0;
     w.body.vel       = Vec3{};
     w.set_Color(ConfigColor::stunned);
@@ -317,18 +317,18 @@ WordAiSystem::enter_Stunned(Text3d& w)
 
 
 void
-WordAiSystem::enter_Idle(Text3d& w)
+WordAiSystem::enter_Idle(Text::Token::Data& w)
 {
-    w.ai.state       = Text3dAI::State::Idle;
+    w.ai.state       = Text::Token::Ai::State::Idle;
     w.ai.state_timer = 0.0;
     w.body.mode      = PhysicsMode::Hover;
     w.body.hover_height = w.ai.hover_height_saved;
-    w.set_Color(TokenColor::get(CppHighlight::classify_Word(w.text)));
+    w.set_Color(Text::Token::get_Color(Text::CppHighlight::classify_Word(w.text)));
 }
 
 
 void
-WordAiSystem::apply_Locomotion(Text3d& w, const Vec3& target_vel, double dt)
+WordAiSystem::apply_Locomotion(Text::Token::Data& w, const Vec3& target_vel, double dt)
 {
     double k = 1.0 - SDL_exp(-ConfigAi::velocity_lerp_rate * dt);
     w.body.vel.x += (target_vel.x - w.body.vel.x) * k;
@@ -354,7 +354,7 @@ WordAiSystem::apply_Locomotion(Text3d& w, const Vec3& target_vel, double dt)
 
 
 void
-WordAiSystem::update_StolenTrail(Text3d& w, double dt)
+WordAiSystem::update_StolenTrail(Text::Token::Data& w, double dt)
 {
     if (w.ai.stolen.empty()) return;
 
@@ -386,7 +386,7 @@ WordAiSystem::update_StolenTrail(Text3d& w, double dt)
     Vec3 perp = perpendicular_horizontal(w.ai.trail_dir);
     double target_yaw = SDL_atan2(-w.ai.trail_dir.x, -w.ai.trail_dir.z);
 
-    for (Text3dPtr& s : w.ai.stolen) {
+    for (Text::TokenPtr& s : w.ai.stolen) {
         if (!s) continue;
         offset += static_cast<double>(s->half_extent) + 0.18;
 
diff --git a/src/systems/word_ai_system.hpp b/src/systems/word_ai_system.hpp
index b0d2033..79a7199 100644
--- a/src/systems/word_ai_system.hpp
+++ b/src/systems/word_ai_system.hpp
@@ -1,7 +1,7 @@
 #pragma once
 
-#include "text_3d/text_3d.hpp"
 #include "utils/globals.hpp"
+#include "text/token.hpp"
 
 
 struct CarriedLine;
@@ -12,31 +12,31 @@ class WordAiSystem
 {
     int _max_active_ai = 5;
 
-    void update_Idle  (Text3d& w, const Player& p, int active_ai_count, double dt);
-    void update_Attack(Text3d& w, const Player& p, CarriedLine& carried,
-                       DynArray<Text3dPtr>& all_words,
-                       DynArray<Text3dPtr>& dropped,
-                       DynArray<Text3dPtr>& released,
+    void update_Idle  (Text::Token::Data& w, const Player& p, int active_ai_count, double dt);
+    void update_Attack(Text::Token::Data& w, const Player& p, CarriedLine& carried,
+                       DynArray<Text::TokenPtr>& all_words,
+                       DynArray<Text::TokenPtr>& dropped,
+                       DynArray<Text::TokenPtr>& released,
                        double dt);
-    void update_Flee  (Text3d& w, const Player& p,
-                       DynArray<Text3dPtr>& released, double dt);
-    void update_Stunned    (Text3d& w, DynArray<Text3dPtr>& released, double dt);
-    void update_StolenTrail(Text3d& w, double dt);
+    void update_Flee  (Text::Token::Data& w, const Player& p,
+                       DynArray<Text::TokenPtr>& released, double dt);
+    void update_Stunned    (Text::Token::Data& w, DynArray<Text::TokenPtr>& released, double dt);
+    void update_StolenTrail(Text::Token::Data& w, double dt);
 
-    void release_StolenWords(Text3d& w, DynArray<Text3dPtr>& out);
+    void release_StolenWords(Text::Token::Data& w, DynArray<Text::TokenPtr>& out);
 
-    void enter_Attack  (Text3d& w);
-    void enter_Flee    (Text3d& w);
-    void enter_Stunned (Text3d& w);
-    void enter_Idle    (Text3d& w);
+    void enter_Attack  (Text::Token::Data& w);
+    void enter_Flee    (Text::Token::Data& w);
+    void enter_Stunned (Text::Token::Data& w);
+    void enter_Idle    (Text::Token::Data& w);
 
-    void apply_Locomotion(Text3d& w, const Vec3& target_vel, double dt);
+    void apply_Locomotion(Text::Token::Data& w, const Vec3& target_vel, double dt);
 public:
     void set_MaxActiveAi (int n) { _max_active_ai = n; }
 
-    void update (DynArray<Text3dPtr>& words,
+    void update (DynArray<Text::TokenPtr>& words,
                  const Player& player,
                  CarriedLine& carried,
-                 DynArray<Text3dPtr>& dropped,
+                 DynArray<Text::TokenPtr>& dropped,
                  double dt);
 };
diff --git a/src/systems/word_anim.cpp b/src/systems/word_anim.cpp
index df56a02..36bf2a2 100644
--- a/src/systems/word_anim.cpp
+++ b/src/systems/word_anim.cpp
@@ -22,7 +22,7 @@ void
 WordAnimSystem::update(DynArray<WordAnim_Add>&  anims_add,
                        DynArray<WordAnim_Drop>& anims_drop,
                        CarriedLine&             carried,
-                       DynArray<Text3dPtr>&     words,
+                       DynArray<Text::TokenPtr>&     words,
                        double                   dt)
 {
     double floor_y = World::get_FloorY<double>();
@@ -72,7 +72,7 @@ WordAnimSystem::update(DynArray<WordAnim_Add>&  anims_add,
             drop.word->body.vel.y  = 0.0;
             drop.word->body.mode   = PhysicsMode::Dynamic;
             if (drop.word->type == ObjectType::Text)
-                drop.word->set_Look(ConfigColor::keep, Text3d::depth_floor);
+                drop.word->set_Look(ConfigColor::keep, Text::Token::depth_floor);
             words.push_back(std::move(drop.word));
         }
     }
diff --git a/src/systems/word_anim.hpp b/src/systems/word_anim.hpp
index 500ae93..733261e 100644
--- a/src/systems/word_anim.hpp
+++ b/src/systems/word_anim.hpp
@@ -1,8 +1,8 @@
 #pragma once
 
 #include "systems/carry_system.hpp"
-#include "text_3d/text_3d.hpp"
 #include "utils/globals.hpp"
+#include "text/token.hpp"
 
 
 struct WordAnim_Add
@@ -14,7 +14,7 @@ struct WordAnim_Add
     double pitch_target {0.0};
     double t            {0.0};
 
-    Text3dPtr word;
+    Text::TokenPtr word;
 
     Vec3 start_pos;
     Vec3 target_pos;
@@ -25,15 +25,15 @@ struct WordAnim_Drop
 {
     bool complete{false};
 
-    Text3dPtr word;
+    Text::TokenPtr word;
 };
 
 
 struct WordAnimSystem
 {
-    void update(DynArray<WordAnim_Add>&  anims_add,
-                DynArray<WordAnim_Drop>& anims_drop,
-                CarriedLine&             carried,
-                DynArray<Text3dPtr>&     words,
+    void update(DynArray<WordAnim_Add>&   anims_add,
+                DynArray<WordAnim_Drop>&  anims_drop,
+                CarriedLine&              carried,
+                DynArray<Text::TokenPtr>& words,
                 double dt);
 };
diff --git a/src/cpp_token/cpp_highlight.cpp b/src/text/cpp_highlight.cpp
similarity index 63%
rename from src/cpp_token/cpp_highlight.cpp
rename to src/text/cpp_highlight.cpp
index 07c3645..32b01dd 100644
--- a/src/cpp_token/cpp_highlight.cpp
+++ b/src/text/cpp_highlight.cpp
@@ -3,31 +3,32 @@
 #include <tree_sitter/api.h>
 #include <unordered_map>
 
-#include "cpp_token/cpp_highlight.hpp"
+#include "text/text.hpp"
+#include "text/token.hpp"
 
 extern "C" const TSLanguage* tree_sitter_cpp(void);
 
 namespace {
 
-// Map a node's type string (+ its parent's) to our TokenType.
+// Map a node's type string (+ its parent's) to our Text::Token::Type.
 // Returns false if this node should not emit a span (recurse into children).
 bool
-classify_Node(TSNode node, TokenType& out)
+classify_Node(TSNode node, Text::Token::Type& out)
 {
     const char* t = ts_node_type(node);
 
     // Leaf categories that color directly.
-    if (std::strcmp(t, "comment") == 0)            { out = TokenType::Comment;    return true; }
+    if (std::strcmp(t, "comment") == 0)            { out = Text::Token::Type::Comment;    return true; }
     if (std::strcmp(t, "string_literal") == 0 ||
         std::strcmp(t, "raw_string_literal") == 0 ||
         std::strcmp(t, "char_literal") == 0 ||
-        std::strcmp(t, "system_lib_string") == 0)  { out = TokenType::StringLit;  return true; }
-    if (std::strcmp(t, "number_literal") == 0)     { out = TokenType::NumberLit;  return true; }
+        std::strcmp(t, "system_lib_string") == 0)  { out = Text::Token::Type::StringLit;  return true; }
+    if (std::strcmp(t, "number_literal") == 0)     { out = Text::Token::Type::NumberLit;  return true; }
     if (std::strcmp(t, "primitive_type") == 0 ||
         std::strcmp(t, "type_identifier") == 0 ||
-        std::strcmp(t, "sized_type_specifier") == 0) { out = TokenType::Type;     return true; }
-    if (std::strcmp(t, "namespace_identifier") == 0) { out = TokenType::Namespace; return true; }
-    if (std::strcmp(t, "field_identifier") == 0)   { out = TokenType::Variable;   return true; }
+        std::strcmp(t, "sized_type_specifier") == 0) { out = Text::Token::Type::Type;     return true; }
+    if (std::strcmp(t, "namespace_identifier") == 0) { out = Text::Token::Type::Namespace; return true; }
+    if (std::strcmp(t, "field_identifier") == 0)   { out = Text::Token::Type::Variable;   return true; }
 
     // Identifiers: depend on parent.
     if (std::strcmp(t, "identifier") == 0) {
@@ -35,20 +36,20 @@ classify_Node(TSNode node, TokenType& out)
         const char* p = ts_node_is_null(parent) ? "" : ts_node_type(parent);
         if (std::strcmp(p, "function_declarator") == 0 ||
             std::strcmp(p, "call_expression") == 0)
-            { out = TokenType::Function; return true; }
-        out = TokenType::Variable; return true;
+            { out = Text::Token::Type::Function; return true; }
+        out = Text::Token::Type::Variable; return true;
     }
 
     // Preprocessor: the #include/#define lines.
-    if (std::strncmp(t, "preproc", 7) == 0)        { out = TokenType::Preprocessor; return true; }
+    if (std::strncmp(t, "preproc", 7) == 0)        { out = Text::Token::Type::Preprocessor; return true; }
 
     return false;   // not a terminal we color — recurse into children
 }
 
 void
-walk(TSNode node, DynArray<HiSpan>& out)
+walk(TSNode node, DynArray<Text::HiSpan>& out)
 {
-    TokenType type;
+    Text::Token::Type type;
     if (ts_node_child_count(node) == 0) {
         // leaf — classify and emit if recognized
         if (classify_Node(node, type))
@@ -67,10 +68,10 @@ walk(TSNode node, DynArray<HiSpan>& out)
 
 } // namespace
 
-DynArray<HiSpan>
-CppHighlight::classify(const std::string& source)
+DynArray<Text::HiSpan>
+Text::CppHighlight::classify(const std::string& source)
 {
-    DynArray<HiSpan> spans;
+    DynArray<Text::HiSpan> spans;
     TSParser* p = ts_parser_new();
     ts_parser_set_language(p, tree_sitter_cpp());
     TSTree* tree = ts_parser_parse_string(p, nullptr, source.c_str(),
@@ -82,10 +83,10 @@ CppHighlight::classify(const std::string& source)
 }
 
 
-TokenType
-CppHighlight::classify_Word(const std::string& word)
+Text::Token::Type
+Text::CppHighlight::classify_Word(const std::string& word)
 {
-    static std::unordered_map<std::string, TokenType> cache;
+    static std::unordered_map<std::string, Text::Token::Type> cache;
     static std::mutex mtx;
 
     {
@@ -95,12 +96,12 @@ CppHighlight::classify_Word(const std::string& word)
     }
 
     // First sight: classify this lone token with tree-sitter.
-    DynArray<HiSpan> spans = classify(word);
+    DynArray<Text::HiSpan> spans = classify(word);
 
     // Pick the span covering the most of the word (usually the only one).
-    TokenType type = TokenType::Identifier;
+    Text::Token::Type type = Text::Token::Type::Identifier;
     size_t best_len = 0;
-    for (const HiSpan& s : spans) {
+    for (const Text::HiSpan& s : spans) {
         size_t len = s.byte_end - s.byte_begin;
         if (len > best_len) { best_len = len; type = s.type; }
     }
diff --git a/src/text/handler.cpp b/src/text/handler.cpp
index 33481b1..58f399b 100644
--- a/src/text/handler.cpp
+++ b/src/text/handler.cpp
@@ -3,33 +3,36 @@
 #include <sstream>
 #include <string>
 
-#include "cpp_token/cpp_highlight.hpp"
-#include "cpp_token/token_color.hpp"
 #include "entities/camera.hpp"
 #include "entities/world.hpp"
 #include "rendering/gl_renderer.hpp"
 #include "text/handler.hpp"
+#include "text/text.hpp"
 #include "utils/config.hpp"
-#include "wall/wall.hpp"            // Wall::Text::tilt_TowardEye, get_Transform
+#include "wall/wall.hpp"
 
-namespace Text
+
+namespace
+Text
 {
 
-// ── monospace metrics (file-local; from wall_connection.cpp) ──────────────────
-namespace {
+namespace
+{
 
-double mono_CharWidth()
+double
+mono_CharWidth()
 {
     static double w = 0.0;
     if (w == 0.0) {
         TokenPtr m1 = make_Token("M",  Token::depth_wall, 0.003f, Vec3{0,0,0});
         TokenPtr m2 = make_Token("MM", Token::depth_wall, 0.003f, Vec3{0,0,0});
-        w = 2.0 * (double)m2->half_extent - 2.0 * (double)m1->half_extent;
+        w = 2.0 * static_cast<double>(m2->half_extent) - 2.0 * static_cast<double>(m1->half_extent);
     }
     return w;
 }
 
-double mono_HalfExtentY()
+double
+mono_HalfExtentY()
 {
     static double h = 0.0;
     if (h == 0.0) {
@@ -39,28 +42,29 @@ double mono_HalfExtentY()
     return h;
 }
 
-// Orange line-number token (digit classifies as a literal, so pin it Static).
-TokenPtr make_Number(const std::string& s, const Vec3& pos, float yaw, float scale)
+
+TokenPtr
+make_Number(const std::string& s, const Vec3& pos, float yaw, float scale)
 {
     TokenPtr t = make_Token(s, Token::depth_wall, scale, pos, ConfigColor::orange);
     t->yaw = yaw;
-    t->set_Color(ConfigColor::orange);   // -> ColorSource::Static
+    t->set_Color(ConfigColor::orange);
     return t;
 }
 
-void set_NumberText(Token* t, const std::string& s)
+
+void
+set_NumberText(Token::Data* t, const std::string& s)
 {
     if (t->text == s) return;
     t->text = s;
     t->rebuild_Mesh();
-    t->set_Color(ConfigColor::orange);
+    t->set_Color   (ConfigColor::orange);
 }
 
 } // namespace
 
 
-// ── construction / config ─────────────────────────────────────────────────────
-
 Handler::Handler(Wall::Role role, float world_scale, LayoutMode mode)
     : _role(role), _layout(mode)
 {
@@ -70,6 +74,7 @@ Handler::Handler(Wall::Role role, float world_scale, LayoutMode mode)
     _append_color = ConfigColor::dark_gray;
 }
 
+
 void
 Handler::set_Layout(LayoutMode m)
 {
@@ -78,6 +83,7 @@ Handler::set_Layout(LayoutMode m)
     relayout();
 }
 
+
 void
 Handler::set_ShowNumbers(bool on, bool both_columns)
 {
@@ -85,6 +91,7 @@ Handler::set_ShowNumbers(bool on, bool both_columns)
     _both_columns = both_columns;
 }
 
+
 void
 Handler::set_DefaultStyle(float depth, float scale, double line_height, double word_spacing)
 {
@@ -95,18 +102,17 @@ Handler::set_DefaultStyle(float depth, float scale, double line_height, double w
 }
 
 
-// ── content ─────────────────────────────────────────────────────────────────
-
 void
 Handler::clear()
 {
-    _lines.clear();
-    _last_src.clear();
-    _label_off.clear();
+    _lines     .clear();
+    _last_src  .clear();
+    _label_off .clear();
     _label_text.clear();
     mark_Dirty();
 }
 
+
 void
 Handler::set_Lines(const DynArray<std::string>& lines)
 {
@@ -133,10 +139,10 @@ Handler::set_Lines(const DynArray<std::string>& lines)
     mark_Dirty();
 }
 
+
 void
 Handler::append_Line(const std::string& text)
 {
-    // Scroll existing rows up, drop the new line at the baseline (program wall).
     for (Line& l : _lines) {
         l.y += _line_height;
         for (TokenPtr& t : l.tokens) if (t) t->pos.y += _line_height;
@@ -149,21 +155,16 @@ Handler::append_Line(const std::string& text)
     t->yaw   = _tf.yaw;
     t->pos   = _tf.position;
     t->pos.y = _append_y;
-    t->set_Color(_append_color);   // Static; program output isn't lexed
+    t->set_Color(_append_color);
     ln.tokens.push_back(std::move(t));
 
     _lines.push_back(std::move(ln));
 }
 
 
-// ── ownership transfer ─────────────────────────────────────────────────────────
-
 TokenPtr
-Handler::detach(Token* t)
+Handler::detach(Token::Data* t)
 {
-    // While editing, the buffer is authoritative — picking a word off the wall
-    // mid-edit would desync it. PickSystem should skip an edit_Active() handler;
-    // refuse here as a backstop.
     if (edit_Active()) return nullptr;
 
     for (size_t li = 0; li < _lines.size(); ++li) {
@@ -182,15 +183,19 @@ Handler::detach(Token* t)
     return nullptr;
 }
 
+
 void
 Handler::attach(DynArray<TokenPtr> tokens, const Vec3& impact)
 {
     if (tokens.empty()) return;
-    if (_layout == LayoutMode::Mono) attach_Mono   (tokens, impact);
-    else                             attach_Snapped(tokens, impact);
+    if (_layout == LayoutMode::Mono)
+         attach_Mono   (tokens, impact);
+    else attach_Snapped(tokens, impact);
+
     mark_Dirty();
 }
 
+
 void
 Handler::clear_ToFloor(DynArray<TokenPtr>& out)
 {
@@ -201,10 +206,10 @@ Handler::clear_ToFloor(DynArray<TokenPtr>& out)
             t->body.mode         = PhysicsMode::Dynamic;
             t->body.vel          = { 0.0, 1.5, 0.0 };
             t->body.hover_height = hover;
-            t->set_Color(ConfigColor::floor);   // matches old clear_ToFloor (blue)
-            t->set_Depth(Token::depth_floor);
-            t->set_ColorSource(ColorSource::Lint);   // free word: reclassifies on AI idle
-            out.push_back(std::move(t));
+            t->set_Color  (ConfigColor::floor);
+            t->set_Depth  (Token::depth_floor);
+            t->set_ColorSource (ColorSource::Lint);
+            out.push_back (std::move(t));
         }
     }
     _lines.clear();
@@ -212,8 +217,6 @@ Handler::clear_ToFloor(DynArray<TokenPtr>& out)
 }
 
 
-// ── Snapped placement (ported from Wall::Editor) ───────────────────────────────
-
 Handler::Target
 Handler::resolve_Target(double y) const
 {
@@ -280,6 +283,7 @@ Handler::resolve_Target(double y) const
     }
 }
 
+
 Line&
 Handler::apply_Target(const Target& t)
 {
@@ -302,10 +306,6 @@ Handler::apply_Target(const Target& t)
     fresh.y           = t.new_y;
     fresh.line_height = _line_height;
 
-    // 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),
@@ -313,6 +313,7 @@ Handler::apply_Target(const Target& t)
     return *it;
 }
 
+
 void
 Handler::attach_Snapped(DynArray<TokenPtr>& block, const Vec3& impact)
 {
@@ -344,8 +345,8 @@ Handler::attach_Snapped(DynArray<TokenPtr>& block, const Vec3& impact)
     Target t      = resolve_Target(impact.y);
     Line&  target = apply_Target(t);
 
-    auto left_edge  = [](const Token* w){ return w->pos.z + (double)w->half_extent; };
-    auto right_edge = [](const Token* w){ return w->pos.z - (double)w->half_extent; };
+    auto left_edge  = [](const Token::Data* w){ return w->pos.z + (double)w->half_extent; };
+    auto right_edge = [](const Token::Data* w){ return w->pos.z - (double)w->half_extent; };
 
     size_t insert_index = 0;
     for (const TokenPtr& w : target.tokens)
@@ -395,8 +396,6 @@ Handler::attach_Snapped(DynArray<TokenPtr>& block, const Vec3& impact)
 }
 
 
-// ── Mono shoot-merge ────────────────────────────────────────────────────────────
-
 bool
 Handler::impact_To_RowCol(const Vec3& impact, int& row, int& col) const
 {
@@ -429,6 +428,7 @@ Handler::impact_To_RowCol(const Vec3& impact, int& row, int& col) const
     return true;
 }
 
+
 void
 Handler::attach_Mono(DynArray<TokenPtr>& block, const Vec3& impact)
 {
@@ -443,7 +443,7 @@ Handler::attach_Mono(DynArray<TokenPtr>& block, const Vec3& impact)
         if (!word.empty()) word += ' ';
         word += w->text;
     }
-    block.clear();              // meshes dropped; re-projection rebuilds them
+    block.clear();
     if (word.empty()) return;
 
     _vim->insert_Word_At(row, col, word);
@@ -453,8 +453,6 @@ Handler::attach_Mono(DynArray<TokenPtr>& block, const Vec3& impact)
 }
 
 
-// ── async lexing (folds in Text::View) ──────────────────────────────────────────
-
 void
 Handler::mark_Dirty()
 {
@@ -462,13 +460,14 @@ Handler::mark_Dirty()
     _dirty = true;
 }
 
+
 std::string
 Handler::render_Source()
 {
     std::string out;
     for (Line& line : _lines) {
         for (size_t i = 0; i < line.tokens.size(); ++i) {
-            Token* t = line.tokens[i].get();
+            Token::Data* t = line.tokens[i].get();
             if (!t) continue;
             if (i) out += ' ';
             t->byte_begin = out.size();
@@ -478,22 +477,23 @@ Handler::render_Source()
         out += '\n';
     }
     _last_src = out;
-    _dirty    = false;   // dispatched
+    _dirty    = false;
     return out;
 }
 
+
 void
 Handler::apply_Highlight(const DynArray<HiSpan>& spans, uint64_t gen)
 {
-    if (gen != _gen) return;   // stale result; a newer edit is pending
+    if (gen != _gen) return;
 
     for (Line& line : _lines) {
         for (TokenPtr& tp : line.tokens) {
-            Token* t = tp.get();
+            Token::Data* t = tp.get();
             if (!t || t->color_src != ColorSource::Lexed) continue;
 
             size_t    best = 0;
-            TokenType type = TokenType::Identifier;
+            Token::Type type = Token::Type::Identifier;
             bool      found = false;
             for (const HiSpan& s : spans) {
                 size_t lo = (t->byte_begin > s.byte_begin) ? t->byte_begin : s.byte_begin;
@@ -504,15 +504,13 @@ Handler::apply_Highlight(const DynArray<HiSpan>& spans, uint64_t gen)
                 }
             }
             if (found)
-                t->color = TokenColor::get(type);   // direct: stays ColorSource::Lexed
+                t->color = Token::get_Color(type);
         }
     }
 }
 
 
-// ── queries ─────────────────────────────────────────────────────────────────────
-
-Token*
+Token::Data*
 Handler::token_At_Byte(size_t off)
 {
     for (Line& line : _lines)
@@ -522,6 +520,7 @@ Handler::token_At_Byte(size_t off)
     return nullptr;
 }
 
+
 DynArray<std::string>
 Handler::as_Lines() const
 {
@@ -539,8 +538,6 @@ Handler::as_Lines() const
 }
 
 
-// ── layout dispatch ─────────────────────────────────────────────────────────────
-
 void
 Handler::relayout()
 {
@@ -549,10 +546,10 @@ Handler::relayout()
     else                                       relayout_Snapped();
 }
 
+
 void
 Handler::relayout_Snapped()
 {
-    // Bulk top->bottom placement (was GameState::editor_PlaceLines).
     const double x_wall = _tf.position.x;
     const double lh     = _line_height;
     const double gap    = 0.5;
@@ -592,12 +589,10 @@ Handler::relayout_Snapped()
     }
 }
 
+
 void
 Handler::relayout_Mono()
 {
-    // Project the vim buffer onto a monospace grid (was Connection::rebuild_Editor),
-    // reusing token meshes for unchanged lines. Coloring is deferred to the async
-    // lex (caller marks dirty); this routine is pure geometry + mesh management.
     if (!_vim) return;
 
     const auto&  blines = _vim->get_Lines();
@@ -715,10 +710,8 @@ Handler::relayout_Mono()
 }
 
 
-// ── line numbers (Snapped) — ported from Wall::Editor::update ──────────────────
-
 void
-Handler::maintain_Numbers(double dt, const Camera& /*cam*/)
+Handler::maintain_Numbers(double dt, const Camera&)
 {
     if (_lines.empty()) return;
 
@@ -787,17 +780,18 @@ Handler::maintain_Numbers(double dt, const Camera& /*cam*/)
     }
 }
 
+
 void
 Handler::apply_Tilt(const Camera& cam)
 {
-    if (_face == FaceMode::None) return;     // Mono grid stays flat (cursor math)
+    if (_face == FaceMode::None) return;
 
     // Phase 1 implements the wall case (Pitch toward eye). Yaw/Both (carried,
     // billboard labels) arrive with the TextLabel fold-in.
     if (_face != FaceMode::Pitch) return;
 
-    auto tilt = [&](Token* t) {
-        if (t) t->pitch = Wall::Text::tilt_TowardEye(t->pos, cam.pos);
+    auto tilt = [&](Token::Data* t) {
+        if (t) t->pitch = Wall::tilt_TwardEye(t->pos, cam.pos);
     };
     for (Line& l : _lines) {
         for (TokenPtr& t : l.tokens) tilt(t.get());
@@ -807,8 +801,6 @@ Handler::apply_Tilt(const Camera& cam)
 }
 
 
-// ── per-frame ─────────────────────────────────────────────────────────────────
-
 void
 Handler::update(double dt, const Camera& cam)
 {
@@ -818,6 +810,7 @@ Handler::update(double dt, const Camera& cam)
     apply_Tilt(cam);
 }
 
+
 void
 Handler::render(GlRenderer& gl, const Camera& cam, const Light& light) const
 {
@@ -831,6 +824,7 @@ Handler::render(GlRenderer& gl, const Camera& cam, const Light& light) const
     }
 }
 
+
 void
 Handler::scroll(double delta)
 {
@@ -843,8 +837,6 @@ Handler::scroll(double delta)
 }
 
 
-// ── line-number shooting (Snapped) ──────────────────────────────────────────────
-
 DynArray<Handler::NumberHit>
 Handler::number_Hitboxes() const
 {
@@ -854,7 +846,7 @@ Handler::number_Hitboxes() const
     DynArray<NumberHit> out;
     out.reserve(_lines.size() * 2);
 
-    auto push_box = [&](const Token* t, size_t i) {
+    auto push_box = [&](const Token::Data* t, size_t i) {
         const double hz = (double)t->half_extent   + PAD;
         const double hy = (double)t->half_extent_y + PAD;
         const Vec3   c  = t->pos;
@@ -870,6 +862,7 @@ Handler::number_Hitboxes() const
     return out;
 }
 
+
 double
 Handler::scroll_DeltaToEye(size_t line_id, double eye_y) const
 {
@@ -877,6 +870,7 @@ Handler::scroll_DeltaToEye(size_t line_id, double eye_y) const
     return eye_y - _lines[line_id].y;
 }
 
+
 void
 Handler::bounce(size_t line_id)
 {
@@ -885,8 +879,6 @@ Handler::bounce(size_t line_id)
 }
 
 
-// ── Mono cursor support ─────────────────────────────────────────────────────────
-
 Handler::MonoMetrics
 Handler::mono_Metrics() const
 {
@@ -900,8 +892,6 @@ Handler::row_Y(int row) const
 }
 
 
-// ── live editing (Mono) ─────────────────────────────────────────────────────────
-
 void
 Handler::begin_Edit(double eye_y)
 {
@@ -914,7 +904,7 @@ Handler::begin_Edit(double eye_y)
     _vim_seen_version = _vim->get_Version();
 
     _eye_y = eye_y;
-    _face  = FaceMode::None;            // flat grid so the cursor math holds
+    _face  = FaceMode::None;
     _status.clear();
 
     _layout = LayoutMode::Mono;
@@ -922,6 +912,7 @@ Handler::begin_Edit(double eye_y)
     mark_Dirty();
 }
 
+
 void
 Handler::end_Edit()
 {
@@ -931,9 +922,10 @@ Handler::end_Edit()
 
     _face   = FaceMode::Pitch;
     _layout = LayoutMode::Snapped;
-    set_Lines(lines);                   // rebuilds + relayout_Snapped + mark_Dirty
+    set_Lines(lines);
 }
 
+
 Handler::EditReq
 Handler::feed_Keys(DynArray<int>& keys, double eye_y)
 {
@@ -972,20 +964,28 @@ Handler::feed_Keys(DynArray<int>& keys, double eye_y)
     return req;
 }
 
+
 void
 Handler::mark_Saved()
 {
     if (_vim) _vim->mark_Saved();
 }
 
+
 int
-Handler::cursor_Row() const { return _vim ? _vim->get_Row() : 0; }
+Handler::cursor_Row() const
+{ return _vim ? _vim->get_Row() : 0; }
+
 
 int
-Handler::cursor_Col() const { return _vim ? _vim->get_Col() : 0; }
+Handler::cursor_Col()
+const { return _vim ? _vim->get_Col() : 0; }
+
 
 VimBuffer::Mode
-Handler::cursor_Mode() const { return _vim ? _vim->get_Mode() : VimBuffer::Mode::Normal; }
+Handler::cursor_Mode() const
+{ return _vim ? _vim->get_Mode() : VimBuffer::Mode::Normal; }
+
 
 const std::string&
 Handler::cmdline_Text() const
@@ -994,8 +994,11 @@ Handler::cmdline_Text() const
     return _vim ? _vim->get_Cmdline() : empty;
 }
 
+
 const DynArray<std::string>&
-Handler::status_Lines() const { return _status; }
+Handler::status_Lines() const
+{ return _status; }
+
 
 std::string
 Handler::modeline() const
@@ -1009,11 +1012,6 @@ Handler::modeline() const
 }
 
 
-// ───────────────────────────── 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)
 {
@@ -1022,6 +1020,7 @@ Handler::set_Style(SDL_FColor color, float depth, float scale)
     _scale       = scale;
 }
 
+
 void
 Handler::set_Centre(const Vec3& p)
 {
@@ -1031,6 +1030,7 @@ Handler::set_Centre(const Vec3& p)
     _anchor_ptr = nullptr;
 }
 
+
 void
 Handler::attach_To(const Vec3* anchor, const Vec3& offset)
 {
@@ -1040,6 +1040,7 @@ Handler::attach_To(const Vec3* anchor, const Vec3& offset)
     _attach_offset = offset;
 }
 
+
 void
 Handler::detach()
 {
@@ -1047,6 +1048,7 @@ Handler::detach()
     _anchor_ptr = nullptr;
 }
 
+
 void
 Handler::set_Text(const std::string& text)
 {
@@ -1056,6 +1058,7 @@ Handler::set_Text(const std::string& text)
     relayout_Billboard();
 }
 
+
 void
 Handler::set_Text(const std::string& text, SDL_FColor color, float depth, float scale)
 {
@@ -1064,6 +1067,7 @@ Handler::set_Text(const std::string& text, SDL_FColor color, float depth, float
     set_Text(text);
 }
 
+
 void
 Handler::set_Collidable(bool on)
 {
@@ -1078,6 +1082,7 @@ Handler::set_Collidable(bool on)
     }
 }
 
+
 const DynArray<TokenPtr>&
 Handler::get_Words() const
 {
@@ -1085,6 +1090,7 @@ Handler::get_Words() const
     return _lines.empty() ? empty : _lines[0].tokens;
 }
 
+
 Vec3
 Handler::label_Centre() const
 {
@@ -1095,6 +1101,7 @@ Handler::label_Centre() const
     return _centre;
 }
 
+
 void
 Handler::relayout_Billboard()
 {
@@ -1139,6 +1146,7 @@ Handler::relayout_Billboard()
     _lines.push_back(std::move(line));
 }
 
+
 void
 Handler::billboard_Update(double dt, const Camera& cam)
 {
@@ -1172,7 +1180,7 @@ Handler::billboard_Update(double dt, const Camera& cam)
     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();
+        Token::Data* t = words[i].get();
         if (!t || !t->loaded) continue;
 
         const double off = _label_off[i];
@@ -1201,6 +1209,7 @@ Handler::billboard_Update(double dt, const Camera& cam)
     }
 }
 
+
 void
 Handler::kick(size_t word_index, const Vec3& projectile_vel, const Vec3& normal)
 {
@@ -1220,4 +1229,5 @@ Handler::kick(size_t word_index, const Vec3& projectile_vel, const Vec3& normal)
     v.z += -normal.z * mag;
 }
 
+
 } // namespace Text
diff --git a/src/text/handler.hpp b/src/text/handler.hpp
index d52fb21..555ab51 100644
--- a/src/text/handler.hpp
+++ b/src/text/handler.hpp
@@ -1,60 +1,36 @@
 #pragma once
-//
-// text/handler.hpp  —  Phase 1 contract
-//
-// Text::Handler is the document: the single source of truth for one wall's text.
-// It OWNS its lines/tokens (no more reconstruct-by-sorting in extract_Text, no
-// more parallel Wall::Editor / Text::View / EditorRow). It does layout, async
-// lexing, rotation-to-eye, line numbers, scrolling, render, and the ownership
-// transfer (detach/attach) that the pick/projectile mechanics ride on.
-//
-// One Handler per wall role. Per the agreed split, role-SPECIFIC behavior stays
-// in the systems (program_system feeds lines, compiler writes output,
-// show_TokenInfo writes info rows); the Handler is the layout/lex/render engine
-// those systems push content into.
-//
-// Editor + vim are TWO LAYOUTS of the same lines, never simultaneous:
-//   LayoutMode::Snapped — shoot-and-snap placement (resolve_Target/place_OnLine).
-//   LayoutMode::Mono     — monospace grid; what vim edits on.
-// Switching modes repositions tokens and rebuilds meshes only for changed text,
-// killing the current editor_Clear()+editor_PlaceLines() thrash.
-//
 
 #include <cstdint>
 #include <string>
 
-#include "cpp_token/cpp_highlight.hpp"   // HiSpan
 #include "entities/camera.hpp"
+#include "text/text.hpp"
 #include "text/line.hpp"
 #include "text/token.hpp"
-#include "text/vim_buffer.hpp"        // VimBuffer (owned in Mono mode)
+#include "text/vim_buffer.hpp"
 #include "utils/globals.hpp"
 #include "utils/light.hpp"
-#include "wall/wall.hpp"                 // Wall::Role, Wall::Transform, Wall::Position
+#include "wall/wall.hpp"
 
 class GlRenderer;
 
 namespace Text
 {
 
-enum class LayoutMode : uint8_t { Snapped, Mono, Billboard };
-
 class Handler
 {
 public:
-    // Line-number hitboxes for shooting numbers (was Wall::Editor::NumberHit).
-    // The top bit distinguishes a number hit from a regular token index when a
-    // single id space is shared with the projectile system.
-    static constexpr size_t HIT_BASE = size_t(1) << 32;
+    static constexpr
+           size_t HIT_BASE    = size_t(1) << 32;
     static bool   is_NumberHit(size_t idx) { return idx >= HIT_BASE; }
     static size_t line_Of     (size_t idx) { return idx - HIT_BASE; }
 
     struct NumberHit { size_t index; Vec3 aabb_Min; Vec3 aabb_Max; };
 
-    struct MonoMetrics {                 // fed to the vim cursor renderer
+    struct MonoMetrics {
         double char_w;
         double he_y;
-        double left_z;                   // z of column 0's left edge
+        double left_z;
         double row0_y;
     };
 
@@ -62,10 +38,9 @@ public:
     Handler() = default;
     Handler(Wall::Role role, float world_scale, LayoutMode mode);
 
-    // ── configuration / render options ────────────────────────────────────
-    void set_Layout      (LayoutMode m);              // switch + reposition (no full rebuild)
+    void set_Layout      (LayoutMode m);
     void set_FaceMode    (FaceMode f)  { _face = f; }
-    void set_Lexing      (bool on)     { _lex_enabled = on; }   // info/compiler/program off
+    void set_Lexing      (bool on)     { _lex_enabled = on; }
     void set_ShowNumbers (bool on, bool both_columns = false);
     void set_Bordered    (bool on)     { _bordered  = on; }
     void set_Underline   (bool on)     { _underline = on; }
@@ -76,85 +51,61 @@ public:
 
     // ── content ────────────────────────────────────────────────────────────
     void clear();
-    void set_Lines  (const DynArray<std::string>& lines);  // (re)build from text; relayouts + marks dirty
-    void append_Line(const std::string& text);             // program output: scroll up, add at bottom
-    void set_AppendBaseline(double y) { _append_y = y; }   // bottom row y for append_Line (program wall)
-    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).
-    //         empties + drops the line if it was the last token. Marks dirty.
-    // attach: take ownership of a token and place it.
-    //   Snapped -> resolve_Target + place_OnLine (fuses today's on_LinePlaced +
-    //              Wall::Text::from_Drop).
-    //   Mono    -> SHOOT-MERGE. impact maps to (row,col) via impact_To_RowCol;
-    //              col snaps to the nearest inter-token gap (never bisects a
-    //              word); the token's text is spliced into the edit buffer
-    //              (VimBuffer::insert_Word_At) so it survives the next keystroke.
-    //              The incoming mesh is dropped; re-projection rebuilds it.
-    TokenPtr detach(Token* t);
-    void     attach(DynArray<TokenPtr> tokens, const Vec3& impact);   // a shot run, placed as one block
-    void     clear_ToFloor(DynArray<TokenPtr>& out);       // was Wall::Text::clear_ToFloor
-
-    // ── async lexing (pull model; GameState owns the CppHighlightSystem) ──
-    // Loop:  if (h.is_Dirty()) hl.request(h.render_Source(), h.generation());
-    //        if (hl.poll(spans, gen)) h.apply_Highlight(spans, gen);
-    // render_Source assigns byte_begin/byte_end across all tokens (was
-    // Text::View::render_Source); apply_Highlight is the best-overlap span match
-    // (was Text::View::apply_Spans) and ignores stale generations.
+    void set_Lines  (const DynArray<std::string>& lines);
+    void append_Line(const std::string& text);
+    void set_AppendBaseline(double y) { _append_y = y; }
+    void set_AppendColor   (SDL_FColor c) { _append_color = c; }
+
+    // ── ownership transfer ────────────────────────────────────────────────
+    TokenPtr detach (Token::Data* t);
+    void     attach (DynArray<TokenPtr> tokens, const Vec3& impact);
+    void     clear_ToFloor(DynArray<TokenPtr>& out);
+
+    // ── async lexing ──────────────────────────────────────────────────────
     bool        is_Dirty   () const { return _lex_enabled && _dirty; }
     uint64_t    generation () const { return _gen; }
     std::string render_Source();
     void        apply_Highlight(const DynArray<HiSpan>& spans, uint64_t gen);
     void        mark_Dirty ();
 
-    // ── queries (for PickSystem + projectile landing) ─────────────────────
-    // PickSystem walks get_Lines() across all Handlers, tracking the best
-    // {Handler*, Token*}; PickResult carries those instead of a wall index.
-    const DynArray<Line>& get_Lines() const { return _lines; }
-    Token*  token_At_Byte (size_t off);
-    DynArray<std::string> as_Lines() const;                // for vim load + file sync
+    // ── queries ───────────────────────────────────────────────────────────
+    Token::Data*                token_At_Byte(size_t off);
+    DynArray<std::string> as_Lines     () const;
+    const DynArray<Line>& get_Lines    () const { return _lines; }
 
     // ── per-frame ──────────────────────────────────────────────────────────
-    void update(double dt, const Camera& cam);   // bounce springs, tilt-to-eye, number placement
-    void render(GlRenderer& gl, const Camera& cam, const Light& light) const;
-    void scroll(double delta);                    // shift every row + its tokens
+    void update (double dt, const Camera& cam);
+    void render (GlRenderer& gl, const Camera& cam, const Light& light) const;
+    void scroll (double delta);
 
-    // ── line-number shooting (Snapped/editor) ─────────────────────────────
-    DynArray<NumberHit> number_Hitboxes  () const;
-    double              scroll_DeltaToEye (size_t line_id, double eye_y) const;
-    void                bounce            (size_t line_id);
+    // ── line-number ───────────────────────────────────────────────────────
+    double scroll_DeltaToEye (size_t line_id, double eye_y) const;
+    void   bounce            (size_t line_id);
+    DynArray<NumberHit>
+           number_Hitboxes   () const;
 
     // ── vim cursor support (Mono) ──────────────────────────────────────────
     MonoMetrics mono_Metrics() const;
     double      row_Y       (int row) const;
 
-    // ── live editing (Mono) — Handler owns the VimBuffer ──────────────────
-    // begin_Edit builds the buffer from the current lines and switches to Mono;
-    // Wall::Connection becomes a thin overlay that forwards keys via feed_Keys
-    // and reads cursor_Row/Col/Mode for the cursor box + cmdline. The Write /
-    // WriteQuit / Exit / Discard request that GameState acts on (file sync, end
-    // connection) surfaces from feed_Keys.
+    // ── live editing (Mono) ───────────────────────────────────────────────
     enum class EditReq : uint8_t { None, Write, WriteQuit, Exit, Discard };
 
     void    begin_Edit (double eye_y);
     void    end_Edit   ();
     bool    edit_Active() const { return _layout == LayoutMode::Mono && _vim.get() != nullptr; }
-    EditReq feed_Keys  (DynArray<int>& keys, double eye_y);   // drives vim, re-projects, re-lexes
+    EditReq feed_Keys  (DynArray<int>& keys, double eye_y);
     void    mark_Saved ();
 
-    int                          cursor_Row    () const;
-    int                          cursor_Col    () const;
-    VimBuffer::Mode              cursor_Mode    () const;
-    const std::string&           cmdline_Text   () const;
-    const DynArray<std::string>& status_Lines   () const;
-    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.
+    int cursor_Row  () const;
+    int cursor_Col  () const;
+    const std::string& cmdline_Text() const;
+    std::string        modeline    () const;
+    VimBuffer::Mode    cursor_Mode () const;
+    const DynArray<std::string>&
+                       status_Lines() const;
+
+    // ── billboard label ───────────────────────────────────────────────────
     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);
@@ -166,26 +117,26 @@ public:
     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;
+    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
+    DynArray<Line> _lines;
 
     Wall::Role      _role   { Wall::Role::Editor };
-    Wall::Transform _tf     {};            // cached world placement (pos + yaw)
+    Wall::Transform _tf     {};
     LayoutMode      _layout { LayoutMode::Snapped };
     FaceMode        _face   { FaceMode::Pitch };
 
-    // style defaults (per-line may override)
     float  _depth        { Token::depth_wall };
     float  _scale        { 0.003f };
     double _line_height  { 0.0 };
     double _word_spacing { 0.0 };
-    double _append_y     { 0.0 };   // program-wall bottom row
-    SDL_FColor _append_color { 0.45f, 0.45f, 0.45f, 1.0f };  // append_Line text color
+    double _append_y     { 0.0 };
+    SDL_FColor _append_color { 0.45f, 0.45f, 0.45f, 1.0f };
 
-    DynArray<std::string> _status;  // multi-line status (vim :help etc.), for the overlay
+    DynArray<std::string> _status;
 
     bool _lex_enabled  { true };
     bool _show_numbers { false };
@@ -205,40 +156,44 @@ private:
     double _row0_y     { 0.0 };
     double _eye_y      { 0.0 };
 
-    // Edit model — present only while editing in Mono mode (the editor Handler).
     cpputils::unique_ptr<VimBuffer> _vim;
     unsigned                        _vim_seen_version { 0 };
 
-    // ── billboard-label state (LayoutMode::Billboard) ─────────────────────
     enum class PositionMode : uint8_t { Absolute, Anchor };
-    PositionMode _pos_mode    { PositionMode::Absolute };
-    const Vec3*  _anchor_ptr  { nullptr };
-    Vec3         _centre        {};
+    const Vec3*  _anchor_ptr    { nullptr };
+    bool         _billboard     { true };
+    bool         _collidable    { true };
+    float        _face_yaw      { 0.0f };
     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
+    Vec3         _centre        {};
+    SDL_FColor   _style_color   { 1.0f, 1.0f, 0.0f, 1.0f };
+    std::string  _label_text;   
+    PositionMode _pos_mode      { PositionMode::Absolute };
+    DynArray<double> _label_off;
+
+    void   relayout_Billboard();
     void   billboard_Update  (double dt, const Camera& cam);
     Vec3   label_Centre      () const;
 
     // layout
-    void   relayout       ();              // dispatch on _layout
+    void   relayout       ();
     void   relayout_Snapped();
     void   relayout_Mono   ();
-    // Snapped placement internals (lifted from Wall::Editor)
-    struct Target { enum Kind { Merge, Insert } kind; size_t line_index; double new_y; double push_dist; };
-    Target resolve_Target (double y) const;
-    Line&  apply_Target   (const Target& t);
-    void   attach_Snapped (DynArray<TokenPtr>& block, const Vec3& impact);
-    void   attach_Mono    (DynArray<TokenPtr>& block, const Vec3& impact);
-    void   maintain_Numbers(double dt, const Camera& cam);   // Snapped: rank + bounce + place
-    void   apply_Tilt     (const Camera& cam);               // FaceMode pitch toward eye
-    // Mono shoot-merge: world impact -> buffer (row,col), col snapped to a gap.
+
+    // Snapped placement internals
+    struct Target {
+        enum   Kind { Merge, Insert } kind;
+        size_t line_index;
+        double new_y;
+        double push_dist;
+    };
+
+    Target resolve_Target  (double y) const;
+    Line&  apply_Target    (const Target& t);
+    void   attach_Snapped  (DynArray<TokenPtr>& block, const Vec3& impact);
+    void   attach_Mono     (DynArray<TokenPtr>& block, const Vec3& impact);
+    void   maintain_Numbers(double dt, const Camera& cam);
+    void   apply_Tilt      (const Camera& cam);
     bool   impact_To_RowCol(const Vec3& impact, int& row, int& col) const;
 };
 
diff --git a/src/text/line.cpp b/src/text/line.cpp
index b7e8edb..a6252b4 100644
--- a/src/text/line.cpp
+++ b/src/text/line.cpp
@@ -16,7 +16,7 @@ Line::width(double gap) const
     return w;
 }
 
-Token*
+Token::Data*
 Line::token_At_Byte(size_t off) const
 {
     for (const TokenPtr& t : tokens)
diff --git a/src/text/line.hpp b/src/text/line.hpp
index 8662e90..0f90ddb 100644
--- a/src/text/line.hpp
+++ b/src/text/line.hpp
@@ -4,8 +4,8 @@
 //
 // Text::Line is the single owning line structure. It replaces three things that
 // each modeled "a row of tokens" for one context:
-//   - Wall::Editor::Line   (non-owning Text3d*, line numbers, bounce)
-//   - Text::Line/Token     (logical row for tree-sitter, non-owning view ptr)
+//   - Wall::Editor::Line   (non-owning Text::Token::Data*, line numbers, bounce)
+//   - Text::Line/Token::Data     (logical row for tree-sitter, non-owning view ptr)
 //   - EditorRow            (vim mode's parallel owned meshes)
 //
 // A Line OWNS its tokens. Layout that needs cross-line context (snapping,
@@ -20,36 +20,5 @@
 namespace Text
 {
 
-struct Line
-{
-    DynArray<TokenPtr> tokens;       // OWNS the row's word meshes, visual order
-
-    double y            { 0.0 };     // world-space row height (shared by tokens)
-    double line_height  { 0.0 };     // spacing to the next row (0 = use Handler default)
-    double word_spacing { 0.0 };     // gap between tokens (0 = use Handler default)
-
-    // Line numbering. The Handler decides numbering policy (one vs two columns,
-    // visibility); the meshes + per-row bounce spring live here because the
-    // bounce is driven per-line when a number is shot (Wall::Editor::bounce).
-    int      number      { -1 };     // 1-based; -1 = unassigned
-    bool     show_number { false };
-    double   bounce_off  { 0.0 };
-    double   bounce_vel  { 0.0 };
-    TokenPtr num_left;               // left gutter digit (always, when shown)
-    TokenPtr num_right;              // right column digit (editor's both_columns only)
-
-    bool   is_Empty() const { return tokens.empty(); }
-    size_t count   () const { return tokens.size(); }
-
-    // Total visual width = sum of glyph widths + internal gaps. Used by Handler
-    // layout (block centering, snap). gap defaults to the line/Handler spacing.
-    double width(double gap) const;
-
-    // Find the owned token covering a byte offset in the Handler's rendered
-    // source (byte_begin/byte_end are assigned by Handler::render_Source).
-    Token* token_At_Byte(size_t off) const;
-
-    void clear();   // drops tokens + number meshes, resets bounce
-};
 
 } // namespace Text
diff --git a/src/text/text.cpp b/src/text/text.cpp
index e69de29..696bfb1 100644
--- a/src/text/text.cpp
+++ b/src/text/text.cpp
@@ -0,0 +1,91 @@
+#include "text.hpp"
+
+
+namespace Text
+{
+
+DynArray<std::string>
+wrap_String(const std::string& text, size_t max_chars)
+{
+    std::string word;
+    std::string current_line;
+
+    DynArray<std::string> lines;
+
+    auto flush_line = [&]() {
+        while (!current_line.empty() && current_line.back() == ' ')
+            current_line.pop_back();
+        lines.push_back(current_line);
+        current_line.clear();
+    };
+
+    auto flush_word = [&]() {
+        if (word.empty()) return;
+        if (current_line.size() + word.size() + (current_line.empty() ? 0 : 1)
+                > max_chars) {
+            flush_line();
+        }
+        if (!current_line.empty()) current_line += ' ';
+        current_line += word;
+        word.clear();
+    };
+
+    for (char c : text) {
+        if (c == '\n') {
+            flush_word();
+            flush_line();
+        }
+        else if (c == ' ' || c == '\t') {
+            flush_word();
+        }
+        else {
+            word += c;
+        }
+    }
+    flush_word();
+    flush_line();
+
+    return lines;
+}
+
+std::string
+ascii_Sanitize(const std::string& in)
+{
+    std::string out;
+    out.reserve(in.size());
+
+    size_t i = 0;
+    while (i < in.size()) {
+        const unsigned char c = static_cast<unsigned char>(in[i]);
+
+        if (c < 0x80) {
+            out += static_cast<char>(c);
+            ++i;
+            continue;
+        }
+
+        // U+2000–U+20FF punctuation block: E2 80 xx
+        if (c == 0xE2 && i + 2 < in.size() &&
+            static_cast<unsigned char>(in[i + 1]) == 0x80) {
+            const unsigned char c3 = static_cast<unsigned char>(in[i + 2]);
+            switch (c3) {
+                case 0x98: case 0x99: out += '\''; break;  // ‘ ’
+                case 0x9C: case 0x9D: out += '"';  break;  // “ ”
+                case 0x93: case 0x94: out += '-';  break;  // – —
+                default:              out += '\''; break;
+            }
+            i += 3;
+            continue;
+        }
+
+        // Any other multibyte sequence: one '?', skip continuation bytes.
+        out += '?';
+        ++i;
+        while (i < in.size() &&
+               (static_cast<unsigned char>(in[i]) & 0xC0) == 0x80)
+            ++i;
+    }
+    return out;
+}
+
+} // namespace Text
diff --git a/src/text/text.hpp b/src/text/text.hpp
index e69de29..13b0103 100644
--- a/src/text/text.hpp
+++ b/src/text/text.hpp
@@ -0,0 +1,88 @@
+#pragma once
+
+#include <string>
+
+#include "utils/globals.hpp"
+
+
+namespace
+Text
+{
+    class  Handler;
+    struct Line;
+    struct HiSpan;
+
+    namespace Token {
+        struct Ai;
+        struct Data;
+        struct Desc;
+        enum class Type : uint8_t;
+    }
+
+
+    enum class ColorSource : uint8_t { Lexed, Lint, Static };
+    enum class FaceMode    : uint8_t { None, Yaw, Pitch, Both };
+    enum class LayoutMode  : uint8_t { Snapped, Mono, Billboard };
+
+
+    DynArray<std::string>
+        wrap_String(const std::string& text, size_t max_chars);
+
+    std::string
+        ascii_Sanitize(const std::string& in);
+
+} // namespace Text
+
+
+namespace
+Text
+{
+
+    struct HiSpan
+    {
+        size_t    byte_begin;
+        size_t    byte_end;
+        Text::Token::Type type;
+    };
+
+
+    namespace CppHighlight
+    {
+        DynArray<HiSpan>  classify     (const std::string& source);
+        Text::Token::Type classify_Word(const std::string& word);
+    }
+
+} // namespace Text
+
+
+namespace
+Text
+{
+
+    struct Line
+    {
+        DynArray<TokenPtr> tokens;
+
+        double y            { 0.0 };
+        double line_height  { 0.0 };
+        double word_spacing { 0.0 };
+
+        int      number      { -1 };
+        bool     show_number { false };
+        double   bounce_off  { 0.0 };
+        double   bounce_vel  { 0.0 };
+        TokenPtr num_left;
+        TokenPtr num_right;
+
+        bool   is_Empty() const { return tokens.empty(); }
+        size_t count   () const { return tokens.size(); }
+
+        double width(double gap) const;
+
+        Token::Data* token_At_Byte(size_t off) const;
+
+        void clear();
+    };
+
+
+} // namespace Text
diff --git a/src/text_3d/text_mesh.cpp b/src/text/text_mesh.cpp
similarity index 100%
rename from src/text_3d/text_mesh.cpp
rename to src/text/text_mesh.cpp
diff --git a/src/text_3d/text_mesh.hpp b/src/text/text_mesh.hpp
similarity index 100%
rename from src/text_3d/text_mesh.hpp
rename to src/text/text_mesh.hpp
diff --git a/src/text/token.cpp b/src/text/token.cpp
index 65e383e..6cb6946 100644
--- a/src/text/token.cpp
+++ b/src/text/token.cpp
@@ -1,25 +1,15 @@
-// text/token.cpp
-//
-// Port of text_3d.cpp onto Text::Token. Mesh build, GPU caching, carry-follow
-// update, and the AI sparkle tail are carried over unchanged. The only behavior
-// changes are in color routing (ColorSource instead of the set_Look sentinel)
-// and AI ownership (optional component, constructed by enable_Ai()).
-
 #include <SDL3/SDL.h>
 
-#include "cpp_token/token_color.hpp"
 #include "entities/camera.hpp"
 #include "entities/world.hpp"
 #include "rendering/gl_renderer.hpp"
-#include "systems/cpp_highlight_system.hpp"   // CppHighlight::classify_Word
+#include "systems/cpp_highlight_system.hpp"
 #include "text/token.hpp"
 #include "utils/globals.hpp"
 
 namespace Text
 {
 
-// ── sparkle-tail helpers (file-local; unchanged from text_3d.cpp) ─────────────
-
 static SDL_FColor
 aiGlintColor(Token::Ai::State s)
 {
@@ -38,7 +28,7 @@ static Vec3 v_norm(const Vec3& v)
     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.
+// Camera-facing filled disc (triangle fan)
 static void
 append_disc(DynArray<float>& v, DynArray<int>& idx,
             const Vec3& c, const Vec3& right, const Vec3& up,
@@ -85,9 +75,10 @@ static SDL_FColor mix_col(SDL_FColor a, SDL_FColor b, float k)
 }
 
 
-// ── construction / lifetime ───────────────────────────────────────────────────
-
-Token::Token(const std::string& txt,
+namespace
+Token
+{
+Data::Data(const std::string& txt,
              float dpth, float scle,
              Vec3 position, SDL_FColor colr)
      : GameObject(position, ObjectType::Text,
@@ -95,23 +86,20 @@ Token::Token(const std::string& txt,
                   true, true, true),
        text(txt), color(colr), depth(dpth), scale(scle)
 {
-    // Lone-token colour (Lint). Editor tokens get switched to Lexed by the Handler.
-    set_Color(TokenColor::get(CppHighlight::classify_Word(text)));
+    set_Color(Text::Token::get_Color(CppHighlight::classify_Word(text)));
     rebuild_Mesh();
 
     body.hover_phase = Random::unit() * SDL_PI_D * 2.0;
 
-    // AI personality (by value, like the old Text3d ctor).
     ai.aggressiveness = Random::unit();
     ai.wiggle_phase   = Random::unit() * SDL_PI_D * 2.0;
-    ai.wiggle_freq    = 3.0 + Random::unit() * 4.0;   // 3–7 rad/s
+    ai.wiggle_freq    = 3.0 + Random::unit() * 4.0;
     ai.speed          = 2.5 + Random::unit() * 2.5;
 }
 
 
-Token::~Token()
+Data::~Data()
 {
-    // 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)
@@ -119,46 +107,40 @@ Token::~Token()
 }
 
 
-// ── color ────────────────────────────────────────────────────────────────────
-
 void
-Token::set_Color(SDL_FColor c)
+Data::set_Color(SDL_FColor c)
 {
-    color = c;                         // direct; color_src unchanged (old semantics)
+    color = c;
 }
 
 void
-Token::set_Look(SDL_FColor c, float new_depth)
+Data::set_Look(SDL_FColor c, float new_depth)
 {
-    // Legacy shim. ConfigColor::keep == {0,-1,0,1} hits the g==-1 "keep" branch.
-    if      (c.r == -1.0f) color = TokenColor::get(CppHighlight::classify_Word(text));
+    if      (c.r == -1.0f) color = Text::Token::get_Color(CppHighlight::classify_Word(text));
     else if (c.g == -1.0f) { /* keep current colour */ }
     else                   color = c;
     set_Depth(new_depth);
 }
 
 void
-Token::recolor()
+Data::recolor()
 {
-    // Lexed → coloured by Handler::apply_Highlight; Static → pinned; Lint → reclassify.
     if (color_src == ColorSource::Lint)
-        color = TokenColor::get(CppHighlight::classify_Word(text));
+        color = Text::Token::get_Color(CppHighlight::classify_Word(text));
 }
 
 
 void
-Token::set_Depth(float new_depth)
+Data::set_Depth(float new_depth)
 {
     if (new_depth == depth) return;
     depth = new_depth;
-    rebuild_Mesh();                    // never touches color
+    rebuild_Mesh();
 }
 
 
-// ── misc ───────────────────────────────────────────────────────────────────────
-
 Vec3
-Token::words_centroid(const DynArray<TokenPtr>& words)
+Data::words_centroid(const DynArray<TokenPtr>& words)
 {
     Vec3   c{};
     double n = 0.0;
@@ -173,19 +155,19 @@ Token::words_centroid(const DynArray<TokenPtr>& words)
 
 
 Vec3
-Token::get_Distance(Vec3 position) const
+Data::get_Distance(Vec3 position) const
 {
     return pos - position;
 }
 
 
 void
-Token::rebuild_Mesh()
+Data::rebuild_Mesh()
 {
     loaded = mesh.build(font_path, text, 64, depth, scale);
     if (!loaded) {
         SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
-                     "Text::Token: mesh build failed for \"%s\"", text.c_str());
+                     "Text::Token::Data: mesh build failed for \"%s\"", text.c_str());
         return;
     }
 
@@ -208,8 +190,7 @@ Token::rebuild_Mesh()
         static_cast<double>(depth) * 0.5
     };
 
-    // Pack the GPU-ready interleaved buffer once. draw() just uploads/binds it.
-    packed_verts.clear();
+    packed_verts.  clear();
     packed_indices.clear();
     const size_t nv = mesh.verts.size();
     packed_verts.reserve(nv * 9);
@@ -227,12 +208,12 @@ Token::rebuild_Mesh()
         packed_indices.push_back(t.b);
         packed_indices.push_back(t.c);
     }
-    gpu_dirty = true;   // mesh changed → re-upload on next draw
+    gpu_dirty = true;
 }
 
 
 void
-Token::update(const Vec3& camera_pos, const double view_mat[9], float carry_dist)
+Data::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]);
@@ -244,10 +225,8 @@ Token::update(const Vec3& camera_pos, const double view_mat[9], float carry_dist
 }
 
 
-// ── draw (word mesh + AI sparkle tail) ─────────────────────────────────────────
-
 void
-Token::draw(GlRenderer& gl, const Camera& camera, const Light& light) const
+Data::draw(GlRenderer& gl, const Camera& camera, const Light& light) const
 {
     if (!loaded || packed_indices.empty()) return;
 
@@ -267,7 +246,6 @@ Token::draw(GlRenderer& gl, const Camera& camera, const Light& light) const
     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());
@@ -276,7 +254,7 @@ Token::draw(GlRenderer& gl, const Camera& camera, const Light& light) const
     gl.set_Emissive (0.0f, 0.0f, 0.0f, 0.0f);
     gl.set_FaceShade(0.0f, 0.0f, 0.0f, 1.0f, false);
 
-    // ── AI sparkle tail (only on active AI state) ─────────────────────────
+    // ── AI sparkle tail ───────────────────────────────────────────────────
     if (ai.state == Ai::State::Idle) return;
 
     const Uint64 now = SDL_GetTicks();
@@ -374,4 +352,8 @@ Token::draw(GlRenderer& gl, const Camera& camera, const Light& light) const
     gl.set_FaceShade(0.0f, 0.0f, 0.0f, 1.0f, false);
 }
 
+
+} // namespace Token
+
+
 } // namespace Text
diff --git a/src/text/token.hpp b/src/text/token.hpp
index d45401c..26088e7 100644
--- a/src/text/token.hpp
+++ b/src/text/token.hpp
@@ -1,72 +1,98 @@
 #pragma once
-//
-// text/token.hpp  —  Phase 1 contract
-//
-// Text::Token is the renamed, cleaned-up successor to Text3d. It is still the
-// atomic unit: ONE whitespace-delimited word == ONE extruded mesh (TextMesh
-// rasterizes the whole string in one pass, so this granularity is fixed by the
-// mesh layer). It remains a GameObject so physics / pick / projectile code keep
-// working unchanged.
-//
-// Three things change vs. Text3d:
-//   1. Color provenance is explicit (ColorSource) instead of the {0,-1,0}
-//      sentinel decoded inside set_Look.
-//   2. byte_begin/byte_end are first-class (were only in Text::View) so a
-//      Handler can map tree-sitter spans straight onto the token.
-//   3. The AI state is an OPTIONAL owned component. Free/floor words own one;
-//      document tokens (editor/program/info) carry nullptr and stay lean.
-//
 
 #include <SDL3/SDL.h>
 #include <cstdint>
 #include <string>
 
-#include "entities/game_object.hpp"     // GameObject, RigidBody, CollisionShape, ObjectType, PhysicsMode
-#include "text_3d/text_mesh.hpp"        // TextMesh
-#include "utils/config.hpp"             // ConfigColor (defaults)
-#include "utils/globals.hpp"            // Vec3, DynArray, cpputils::unique_ptr
+#include "entities/game_object.hpp"
+#include "text/text.hpp"
+#include "text/text_mesh.hpp"
+#include "utils/config.hpp"
+#include "utils/globals.hpp"
 #include "utils/light.hpp"
 
 class Camera;
 class GlRenderer;
 
-namespace Text
+namespace
+Text
 {
 
-// Where a token's color comes from. Replaces the magic-float ConfigColor::keep.
-//   Lexed  — colored by the owning Handler's tree-sitter spans (apply_Highlight).
-//   Lint   — self-classified as a lone token: classify_Word(text). Free words on
-//            the floor, and AI words returning to Idle, use this.
-//   Static — a fixed color set explicitly and never recomputed: line numbers,
-//            program output (dark_gray), AI state tints (attack/flee/stunned),
-//            info/compiler text.
-enum class ColorSource : uint8_t { Lexed, Lint, Static };
-
-// Rotate-to-eye policy. Stored on the Handler (document-wide) and applied to its
-// tokens each frame; kept here so Token and Handler share the vocabulary.
-//   None  — fixed yaw/pitch (free floor words).
-//   Yaw   — follow the eye horizontally (carried words).
-//   Pitch — tilt up/down toward the eye, clamped (wall text: tilt_TowardEye).
-//   Both  — billboard fully (labels).
-enum class FaceMode : uint8_t { None, Yaw, Pitch, Both };
-
-
-struct Token final : public GameObject
+
+namespace
+Token
+{
+    struct Ai;
+    struct Data;
+    struct Desc;
+
+    enum class Type : uint8_t
+    {
+        Delimiter,
+        Identifier,
+        Keyword,
+        Literal,
+        Operator,
+        Preprocessor,
+        Type,
+        Function,
+        Variable,
+        Comment,
+        StringLit,
+        NumberLit,
+        Namespace,
+    };
+
+    // token_db.cpp START
+    inline const std::string font_path =
+     "/usr/share/fonts/dejavu/DejaVuSansMono.ttf";
+
+    inline constexpr float depth_wall     = 0.06f;
+    inline constexpr float depth_floor    = 0.14f;
+    inline constexpr float depth_compiler = 0.02f;
+
+    const char* get_TypeName   (Type type);
+    SDL_FColor  get_Color      (Type t);
+    Desc        get_Description(const std::string& text, Type type);
+    DynArray<std::string>
+                get_TestArrayAllKeywords();
+    // token_db.cpp END
+
+} // namespace Token::Data
+
+
+
+template<typename... Args>
+TokenPtr make_Token(Args&&... args)
+{
+    return cpputils::make_unique<Token::Data>(std::forward<Args>(args)...);
+}
+
+} // namespace Text
+
+
+
+namespace
+Text
 {
-    // ── shared constants (unchanged from Text3d) ──────────────────────────
-    static inline const std::string font_path =
-        "/usr/share/fonts/dejavu/DejaVuSansMono.ttf";
 
-    static inline constexpr float depth_wall     = 0.06f;
-    static inline constexpr float depth_floor    = 0.14f;
-    static inline constexpr float depth_compiler = 0.02f;
 
+namespace
+Token
+{
+
+    struct Desc
+    {
+        std::string short_desc;
+        std::string long_desc;
+    };
 
-    // ── AI component (was Text3dAI; now owned only by free words) ──────────
     struct Ai
     {
         enum class State : uint8_t { Idle, Attack, Flee, Stunned };
 
+        bool   in_pause           = false;
+        bool   has_stolen         = false;
         State  state              = State::Idle;
         double state_timer        = 0.0;
         double aggressiveness     = 0.0;
@@ -75,110 +101,87 @@ struct Token final : public GameObject
         double wiggle_freq        = 4.0;
         double speed              = 2.0;
 
-        bool   in_pause           = false;
         double hover_height_saved = 0.0;
         double orbit_angle        = 0.0;
         double orbit_dir          = 1.0;
-        bool   has_stolen         = false;
 
-        DynArray<cpputils::unique_ptr<Token>> stolen;   // carried-off loot
+        DynArray<cpputils::unique_ptr<Data>> stolen;
         Vec3 trail_dir { 1.0, 0.0, 0.0 };
     };
 
+    struct Data final : public GameObject
+    {
+        std::string text;
+        SDL_FColor  color;
+        TextMesh    mesh;
 
-    // ── text / appearance ─────────────────────────────────────────────────
-    std::string text;
-    SDL_FColor  color;
-    TextMesh    mesh;
-
-    ColorSource color_src { ColorSource::Lint };
+        ColorSource color_src { ColorSource::Lint };
 
-    float depth;
-    float scale;
-    float half_extent   { 0.0f };   // width  half-extent (along the glyph row)
-    float half_extent_y { 0.2f };   // height half-extent
-    float pitch         { 0.0f };
+        float depth;
+        float scale;
+        float half_extent   { 0.0f };
+        float half_extent_y { 0.2f };
+        float pitch         { 0.0f };
 
-    bool  loaded        { false };
-    bool  front_tint    { true };
-    bool  glow_enabled  { false };
-    float glow_strength { 0.2f };
-    float back_dim      { 0.45f };
-    Vec3  ribbon_local  {};
+        bool  loaded        { false };
+        bool  front_tint    { true };
+        bool  glow_enabled  { false };
+        float glow_strength { 0.2f };
+        float back_dim      { 0.45f };
+        Vec3  ribbon_local  {};
 
 
-    // ── byte span (valid only while owned by a Handler; set by render_Source)
-    size_t byte_begin { 0 };
-    size_t byte_end   { 0 };
+        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 {};
+        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) ──
-    Ai ai;
+        Ai ai;
 
 
-    // ── GPU caches (mutable; identical to Text3d) ─────────────────────────
-    mutable bool         gpu_dirty       { true };
-    mutable int          gpu_index_count { 0 };
-    mutable unsigned int gpu_vbo         { 0 };
-    mutable unsigned int gpu_ebo         { 0 };
-    mutable unsigned int gpu_vbo_sparkle { 0 };
-    mutable unsigned int gpu_ebo_sparkle { 0 };
-    mutable int          gpu_sparkle_index_count { 0 };
+        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];
+        static constexpr int SPARKLE_TRAIL = 14;
+        mutable int    sparkle_trail_count { 0 };
+        mutable Uint64 sparkle_last_ms     { 0 };
+        mutable Vec3   sparkle_trail[SPARKLE_TRAIL];
 
-    DynArray<int>   packed_indices;
-    DynArray<float> packed_verts;
+        DynArray<int>   packed_indices;
+        DynArray<float> packed_verts;
 
 
-    // ── lifetime ───────────────────────────────────────────────────────────
-    Token(const std::string& txt,
-          float depth, float scale,
-          Vec3  position,
-          SDL_FColor colr = ConfigColor::blue);
-    ~Token() override;
+        Data (const std::string& txt,
+              float depth, float scale,
+              Vec3  position,
+              SDL_FColor colr = ConfigColor::blue);
+        ~Data() override;
 
 
-    // ── colour ────────────────────────────────────────────────────────────
-    // set_Color    — set the colour directly (does NOT change color_src; matches
-    //                old Text3d::set_Color, so existing call sites keep working).
-    // set_Look     — legacy shim: ConfigColor::keep keeps colour, an r==-1
-    //                sentinel reclassifies, otherwise sets colour; then depth.
-    // recolor      — Lint: reclassify from text. Lexed/Static: no-op.
-    // color_src is only consulted by Handler::apply_Highlight (which recolours
-    // its Lexed tokens) and recolor(); free/floor tokens ignore it.
-    void set_Color      (SDL_FColor c);
-    void set_Look       (SDL_FColor c, float new_depth);
-    void set_ColorSource(ColorSource src) { color_src = src; }
-    void recolor        ();
+        void set_Color      (SDL_FColor c);
+        void set_Look       (SDL_FColor c, float new_depth);
+        void set_ColorSource(ColorSource src) { color_src = src; }
+        void recolor        ();
 
-    void set_Depth   (float depth);   // rebuilds mesh only if depth changed; never touches colour
-    void rebuild_Mesh();
+        void set_Depth   (float depth);
+        void rebuild_Mesh();
 
 
-    // ── misc (unchanged) ───────────────────────────────────────────────────
-    static Vec3 words_centroid(const DynArray<cpputils::unique_ptr<Token>>& words);
-    Vec3 get_Distance(Vec3 position) const;
-    void update      (const Vec3& camera_pos, const double view_mat[9], float carry_dist = 1.5f);
-    void draw        (GlRenderer& gl, const Camera& camera, const Light& light) const override;
-};
+        static Vec3 words_centroid(const DynArray<cpputils::unique_ptr<Data>>& words);
+        Vec3 get_Distance(Vec3 position) const;
+        void update      (const Vec3& camera_pos, const double view_mat[9], float carry_dist = 1.5f);
+        void draw        (GlRenderer& gl, const Camera& camera, const Light& light) const override;
+    };
 
 
-using TokenPtr = cpputils::unique_ptr<Token>;
+} // namespace Token::Data
 
-template<typename... Args>
-TokenPtr make_Token(Args&&... args)
-{
-    return cpputils::make_unique<Token>(std::forward<Args>(args)...);
-}
 
 } // namespace Text
diff --git a/src/cpp_token/token_db.cpp b/src/text/token_db.cpp
similarity index 71%
rename from src/cpp_token/token_db.cpp
rename to src/text/token_db.cpp
index e4894d0..9b95079 100644
--- a/src/cpp_token/token_db.cpp
+++ b/src/text/token_db.cpp
@@ -1,7 +1,9 @@
-#include "token_db.hpp"
 #include <unordered_map>
 
-namespace {
+#include "token.hpp"
+
+namespace
+{
 
 struct Entry { const char* short_desc; const char* long_desc; };
 
@@ -92,34 +94,63 @@ table()
     return m;
 }
 
-// Generic fallback description per token type, for tokens not in the table
-// (e.g. user-named variables, arbitrary literals).
-TokenDB::Desc
-generic_For(TokenType type)
+
+} // namespace
+
+
+namespace
+Text
+{
+namespace
+Token
+{
+
+SDL_FColor
+get_Color(Type t)
+{
+    switch (t) {
+        case Type::Keyword:     return ConfigColor::orange;
+        case Type::Type:        return ConfigColor::green;
+        case Type::Identifier:  return ConfigColor::light_gray;
+        case Type::Literal:     return ConfigColor::red;
+        case Type::Operator:    return ConfigColor::soft_blue;
+        case Type::Delimiter:   return ConfigColor::slate_blue;
+        case Type::Preprocessor:return ConfigColor::pink;
+        case Type::Function:    return ConfigColor::blue;
+        case Type::Variable:    return ConfigColor::pale_sky;
+        case Type::Comment:     return ConfigColor::gray;
+        case Type::StringLit:   return ConfigColor::lime;
+        case Type::NumberLit:   return ConfigColor::amber;
+        case Type::Namespace:   return ConfigColor::teal;
+    }
+    return ConfigColor::white;
+}
+
+
+Desc
+generic_For(Type type)
 {
     switch (type) {
-        case TokenType::Keyword:      return { "Keyword",      "A C/C++ language keyword" };
-        case TokenType::Type:         return { "Type",         "A data type" };
-        case TokenType::Identifier:   return { "Identifier",   "A name in the program" };
-        case TokenType::Literal:      return { "Literal",      "A literal value" };
-        case TokenType::Operator:     return { "Operator",     "An operator" };
-        case TokenType::Delimiter:    return { "Delimiter",    "A delimiter" };
-        case TokenType::Preprocessor: return { "Preprocessor", "A preprocessor directive" };
-        case TokenType::Function:     return { "Function",     "A function" };
-        case TokenType::Variable:     return { "Variable",     "A variable" };
-        case TokenType::Comment:      return { "Comment",      "A source comment" };
-        case TokenType::StringLit:    return { "String",       "A string literal" };
-        case TokenType::NumberLit:    return { "Number",       "A numeric literal" };
-        case TokenType::Namespace:    return { "Namespace",    "A namespace" };
+        case Type::Keyword:      return { "Keyword",      "A C/C++ language keyword" };
+        case Type::Type:         return { "Type",         "A data type" };
+        case Type::Identifier:   return { "Identifier",   "A name in the program" };
+        case Type::Literal:      return { "Literal",      "A literal value" };
+        case Type::Operator:     return { "Operator",     "An operator" };
+        case Type::Delimiter:    return { "Delimiter",    "A delimiter" };
+        case Type::Preprocessor: return { "Preprocessor", "A preprocessor directive" };
+        case Type::Function:     return { "Function",     "A function" };
+        case Type::Variable:     return { "Variable",     "A variable" };
+        case Type::Comment:      return { "Comment",      "A source comment" };
+        case Type::StringLit:    return { "String",       "A string literal" };
+        case Type::NumberLit:    return { "Number",       "A numeric literal" };
+        case Type::Namespace:    return { "Namespace",    "A namespace" };
     }
     return { "", "" };
 }
 
-} // namespace
-
 
-TokenDB::Desc
-TokenDB::get_Description(const std::string& text, TokenType type)
+Desc
+get_Description(const std::string& text, Type type)
 {
     const auto& t = table();
     auto it = t.find(text);
@@ -130,29 +161,33 @@ TokenDB::get_Description(const std::string& text, TokenType type)
 
 
 const char*
-TokenDB::get_TypeName(TokenType t)
+get_TypeName(Type t)
 {
     switch (t) {
-        case TokenType::Keyword:      return "Keyword";
-        case TokenType::Type:         return "Type";
-        case TokenType::Identifier:   return "Identifier";
-        case TokenType::Literal:      return "Literal";
-        case TokenType::Operator:     return "Operator";
-        case TokenType::Delimiter:    return "Delimiter";
-        case TokenType::Preprocessor: return "Preprocessor";
-        case TokenType::Function:     return "Function";
-        case TokenType::Variable:     return "Variable";
-        case TokenType::Comment:      return "Comment";
-        case TokenType::StringLit:    return "String";
-        case TokenType::NumberLit:    return "Number";
-        case TokenType::Namespace:    return "Namespace";
+        case Type::Keyword:      return "Keyword";
+        case Type::Type:         return "Type";
+        case Type::Identifier:   return "Identifier";
+        case Type::Literal:      return "Literal";
+        case Type::Operator:     return "Operator";
+        case Type::Delimiter:    return "Delimiter";
+        case Type::Preprocessor: return "Preprocessor";
+        case Type::Function:     return "Function";
+        case Type::Variable:     return "Variable";
+        case Type::Comment:      return "Comment";
+        case Type::StringLit:    return "String";
+        case Type::NumberLit:    return "Number";
+        case Type::Namespace:    return "Namespace";
     }
     return "Unknown";
 }
 
 
 DynArray<std::string>
-TokenDB::get_TestArrayAllKeywords()
+get_TestArrayAllKeywords()
 {
     return { "float", "return", "main", "0", "++", "(", ",", ";", "\"", "\"", "#define" };
 }
+
+
+} // namespace TokenDB
+} // namespace Text
diff --git a/src/text_3d/text_3d.hpp b/src/text_3d/text_3d.hpp
deleted file mode 100644
index 0890f82..0000000
--- a/src/text_3d/text_3d.hpp
+++ /dev/null
@@ -1,25 +0,0 @@
-#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 <utility>
-
-#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>
-Text3dPtr
-make_Text3d(Args&&... args)
-{
-    return Text::make_Token(std::forward<Args>(args)...);
-}
diff --git a/src/utils/frame_profiler.hpp b/src/utils/frame_profiler.hpp
index 9a8274a..3fce987 100644
--- a/src/utils/frame_profiler.hpp
+++ b/src/utils/frame_profiler.hpp
@@ -165,7 +165,7 @@ struct ScopedZone {
 
 } // namespace fp
 
-// Token-pasting line counter so two FP_ZONEs in the same scope don't collide.
+// Token::Data-pasting line counter so two FP_ZONEs in the same scope don't collide.
 #define FP_CONCAT_(a,b) a##b
 #define FP_CONCAT(a,b)  FP_CONCAT_(a,b)
 #define FP_ZONE(name)   ::fp::ScopedZone FP_CONCAT(_fp_zone_, __LINE__){name}
diff --git a/src/utils/globals.hpp b/src/utils/globals.hpp
index d225217..295f801 100644
--- a/src/utils/globals.hpp
+++ b/src/utils/globals.hpp
@@ -9,19 +9,28 @@
 #include "math.hpp"
 
 
-class  GlRenderer;
-struct CarriedWordNode;
-struct GameObject;
-namespace Text { struct Token; }
-
 using Math::Vec3;
 
 template<typename T>
-using DynArray           = std::vector<T>;
+using DynArray = std::vector<T>;
+
+namespace Text {
+    namespace Token { struct Data; }
+    using TokenPtr = cpputils::unique_ptr<Token::Data>;
+}
 
-using GlRendererPtr      = cpputils::unique_ptr<GlRenderer>;
-using CarriedWordNodePtr = cpputils::unique_ptr<CarriedWordNode>;
-using GameObjectPtr      = cpputils::unique_ptr<GameObject>;
+namespace Wall {
+    struct TextData;
+    using  TextPtr     = cpputils::unique_ptr<TextData>;
+    using  TextPtrList = DynArray<TextPtr>;
+}
 
-using Text3d    = Text::Token;
-using Text3dPtr = cpputils::unique_ptr<Text::Token>;
+class  GlRenderer;
+using  GlRendererPtr = cpputils::unique_ptr<GlRenderer>;
+
+struct CarriedWordNode;
+using  CarriedWordNodePtr = cpputils::unique_ptr<CarriedWordNode>;
+
+
+struct GameObject;
+using  GameObjectPtr = cpputils::unique_ptr<GameObject>;
diff --git a/src/utils/text.cpp b/src/utils/text.cpp
deleted file mode 100644
index 991604f..0000000
--- a/src/utils/text.cpp
+++ /dev/null
@@ -1,165 +0,0 @@
-#include "text.hpp"
-
-
-namespace Text
-{
-
-DynArray<std::string>
-wrap_String(const std::string& text, size_t max_chars)
-{
-    std::string word;
-    std::string current_line;
-
-    DynArray<std::string> lines;
-
-    auto flush_line = [&]() {
-        while (!current_line.empty() && current_line.back() == ' ')
-            current_line.pop_back();
-        lines.push_back(current_line);
-        current_line.clear();
-    };
-
-    auto flush_word = [&]() {
-        if (word.empty()) return;
-        if (current_line.size() + word.size() + (current_line.empty() ? 0 : 1)
-                > max_chars) {
-            flush_line();
-        }
-        if (!current_line.empty()) current_line += ' ';
-        current_line += word;
-        word.clear();
-    };
-
-    for (char c : text) {
-        if (c == '\n') {
-            flush_word();
-            flush_line();
-        }
-        else if (c == ' ' || c == '\t') {
-            flush_word();
-        }
-        else {
-            word += c;
-        }
-    }
-    flush_word();
-    flush_line();
-
-    return lines;
-}
-
-std::string
-ascii_Sanitize(const std::string& in)
-{
-    std::string out;
-    out.reserve(in.size());
-
-    size_t i = 0;
-    while (i < in.size()) {
-        const unsigned char c = static_cast<unsigned char>(in[i]);
-
-        if (c < 0x80) {
-            out += static_cast<char>(c);
-            ++i;
-            continue;
-        }
-
-        // U+2000–U+20FF punctuation block: E2 80 xx
-        if (c == 0xE2 && i + 2 < in.size() &&
-            static_cast<unsigned char>(in[i + 1]) == 0x80) {
-            const unsigned char c3 = static_cast<unsigned char>(in[i + 2]);
-            switch (c3) {
-                case 0x98: case 0x99: out += '\''; break;  // ‘ ’
-                case 0x9C: case 0x9D: out += '"';  break;  // “ ”
-                case 0x93: case 0x94: out += '-';  break;  // – —
-                default:              out += '\''; break;
-            }
-            i += 3;
-            continue;
-        }
-
-        // Any other multibyte sequence: one '?', skip continuation bytes.
-        out += '?';
-        ++i;
-        while (i < in.size() &&
-               (static_cast<unsigned char>(in[i]) & 0xC0) == 0x80)
-            ++i;
-    }
-    return out;
-}
-
-
-
-void
-SrcView::clear()
-{ lines.clear(); }
-
-
-std::string
-SrcView::render_Source()
-{
-    std::string out;
-    for (SrcLine& line : lines) {
-        for (size_t ti = 0; ti < line.tokens.size(); ++ti) {
-            if (ti) out += ' ';
-            SrcToken& tk = line.tokens[ti];
-            tk.byte_begin = out.size();
-            out += tk.text;
-            tk.byte_end = out.size();
-        }
-        out += '\n';
-    }
-    return out;
-}
-
-
-SrcToken*
-SrcView::token_At(size_t off)
-{
-    for (SrcLine& line : lines)
-        for (SrcToken& tk : line.tokens)
-            if (off >= tk.byte_begin && off < tk.byte_end)
-                return &tk;
-    return nullptr;
-}
-
-
-void
-SrcView::apply_Spans(const DynArray<HiSpan>& spans)
-{
-    for (SrcLine& line : lines) {
-        for (SrcToken& tk : line.tokens) {
-            if (!tk.view) continue;
-
-            size_t best_overlap = 0;
-            TokenType best_type = TokenType::Identifier;
-            bool found = false;
-            for (const HiSpan& s : spans) {
-                size_t lo = (tk.byte_begin > s.byte_begin) ? tk.byte_begin : s.byte_begin;
-                size_t hi = (tk.byte_end   < s.byte_end)   ? tk.byte_end   : s.byte_end;
-                if (lo < hi) {
-                    size_t ov = hi - lo;
-                    if (ov > best_overlap) { best_overlap = ov; best_type = s.type; found = true; }
-                }
-            }
-            if (found) tk.view->set_Color(TokenColor::get(best_type));
-        }
-    }
-}
-
-DynArray<std::string>
-SrcView::as_Lines() const
-{
-    DynArray<std::string> out;
-    for (const SrcLine& line : lines) {
-        std::string s;
-        for (size_t ti = 0; ti < line.tokens.size(); ++ti) {
-            if (ti) s += ' ';
-            s += line.tokens[ti].text;
-        }
-        out.push_back(s);
-    }
-    return out;
-}
-
-} // namespace Text
diff --git a/src/utils/text.hpp b/src/utils/text.hpp
deleted file mode 100644
index aaa1051..0000000
--- a/src/utils/text.hpp
+++ /dev/null
@@ -1,53 +0,0 @@
-#pragma once
-
-#include <string>
-
-#include "cpp_token/cpp_highlight.hpp"
-#include "cpp_token/token_color.hpp"
-#include "text_3d/text_3d.hpp"
-#include "utils/globals.hpp"
-
-
-namespace Text
-{
-
-DynArray<std::string>
-    wrap_String(const std::string& text, size_t max_chars);
-
-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 SrcToken
-{
-    std::string text;
-    size_t      byte_begin { 0 };
-    size_t      byte_end   { 0 };
-    Text3d*     view       { nullptr };
-};
-
-
-struct SrcLine
-{
-    DynArray<SrcToken> tokens;
-};
-
-struct SrcView
-{
-    DynArray<SrcLine> lines;
-
-    void        clear        ();
-    std::string render_Source();
-    SrcToken*   token_At     (size_t off);
-    void        apply_Spans  (const DynArray<HiSpan>& spans);
-    DynArray<std::string>
-                as_Lines     () const;
-};
-
-} // namespace Text
diff --git a/src/wall/editor.cpp b/src/wall/editor.cpp
deleted file mode 100644
index 4d4db46..0000000
--- a/src/wall/editor.cpp
+++ /dev/null
@@ -1,626 +0,0 @@
-#include <SDL3/SDL.h>
-#include <algorithm>
-#include <string>
-
-#include "editor.hpp"
-#include "entities/camera.hpp"
-#include "entities/world.hpp"
-#include "rendering/gl_renderer.hpp"
-
-
-namespace Wall
-{
-
-void
-Editor::clear()
-{
-    _lines.clear();
-}
-
-
-Editor::Line*
-Editor::find_NearestLine(double y)
-{
-    Line*  best    = nullptr;
-    double best_dy = ConfigWall::snap_threshold_y;
-    for (Line& l : _lines) {
-        if (l.words.empty()) continue;
-        const double center_y = l.words.front()->pos.y;   // any word on the row; they share y
-        const double dy = SDL_fabs(center_y - y);
-        if (dy <= best_dy) { best = &l; best_dy = dy; }
-    }
-    return best;
-}
-
-
-Editor::Target
-Editor::resolve_Target(double y) const
-{
-    DynArray<size_t> order;
-    for (size_t i = 0; i < _lines.size(); ++i)
-        if (!_lines[i].words.empty()) order.push_back(i);
-
-    if (order.empty())
-        return { Target::Insert, 0, y };
-
-    std::sort(order.begin(), order.end(),
-        [&](size_t a, size_t b){ return _lines[a].y > _lines[b].y; });
-
-    auto glyph_top = [&](size_t li) {
-        return _lines[li].y
-               + (double)_lines[li].words.front()->half_extent_y
-               + ConfigWall::merge_top_margin;
-    };
-    auto underline = [&](size_t li) {
-        return _lines[li].y - ConfigWall::underline_drop;
-    };
-
-    // 1) Merge: aim within any line's glyph band [underline .. glyph top].
-    for (size_t li : order)
-        if (y <= glyph_top(li) && y >= underline(li))
-            return { Target::Merge, li, 0.0 };
-
-    const double LH = ConfigWall::line_height;
-
-    // 2) Above the top line's glyph → new row at the aim height, but never
-    //    closer than one LH to the current top (keep whatever wider gap the
-    //    shot asked for).
-    {
-        const size_t top = order.front();
-        if (y > glyph_top(top)) {
-            const double min_y = _lines[top].y + LH;      // closest allowed
-            const double new_y = (y > min_y) ? y : min_y; // keep the wide gap if aimed higher
-            return { Target::Insert, top, new_y, 0.0 };
-        }
-    }
-
-    // 3) Between two lines. Decide by where in the gap the aim falls:
-    //    - enough room and aimed in the middle → place AT the aim (no push)
-    //    - too close to the upper line → snap one row under it
-    //    - too close to the lower line → snap one row above it (push lo if needed)
-    for (size_t oi = 0; oi + 1 < order.size(); ++oi) {
-        const size_t hi = order[oi];
-        const size_t lo = order[oi + 1];
-        if (y < underline(hi) && y > glyph_top(lo)) {
-            const double hi_y = _lines[hi].y;
-            const double lo_y = _lines[lo].y;
-            const double gap  = hi_y - lo_y;
-
-            const double free_top = hi_y - LH;
-            const double free_bot = lo_y + LH;
-
-            // Wide gap, aimed in the open middle → place exactly at the aim,
-            // push nothing (there's room on both sides).
-            if (gap >= 2.0 * LH && y <= free_top && y >= free_bot)
-                return { Target::Insert, lo, y, 0.0 };
-
-            // Wide gap but aimed near an edge → snap to that edge, no push
-            // (the gap already has room, we just don't free-float there).
-            if (gap >= 2.0 * LH)
-                return { Target::Insert, lo, (y > free_top) ? free_top : free_bot, 0.0 };
-
-            // Tight gap (< 2*LH): only one placement fits. Sit one row under hi
-            // and push lo+below down by the deficit to restore LH spacing.
-            const double deficit = 2.0 * LH - gap;
-            return { Target::Insert, lo, free_top, deficit };
-        }
-    }
-
-    // 4) Below the bottom line → new row at the aim height, but never closer
-    //    than one LH below the current bottom.
-    {
-        const size_t bot = order.back();
-        const double max_y = _lines[bot].y - LH;          // closest allowed (further down = smaller y)
-        const double new_y = (y < max_y) ? y : max_y;     // keep the wide gap if aimed lower
-        return { Target::Insert, bot, new_y, 0.0 };
-    }
-}
-
-
-Editor::Line&
-Editor::apply_Target(const Target& t)
-{
-    if (t.kind == Target::Merge)
-        return _lines[t.line_index];
-
-    if (t.push_dist > 0.0 && t.line_index < _lines.size()) {
-        const double push_below_y = _lines[t.line_index].y;
-        for (Line& l : _lines) {
-            if (l.y <= push_below_y + 1e-6) {
-                l.y -= t.push_dist;
-                for (Text3d* w : l.words) if (w) w->pos.y -= t.push_dist;
-            }
-        }
-    }
-
-    Line fresh;
-    fresh.y = t.new_y;
-    _lines.push_back(std::move(fresh));
-    return _lines.back();
-}
-
-
-Editor::Placement
-Editor::place_OnLine(Line& line, double new_half_z, double impact_z)
-{
-    if (line.words.empty())
-        return { 0, impact_z };
-
-    const size_t n = line.words.size();
-    auto left_edge  = [](const Text3d* w){ return w->pos.z + (double)w->half_extent; };
-    auto right_edge = [](const Text3d* w){ return w->pos.z - (double)w->half_extent; };
-
-    const Text3d* leftmost  = line.words.front();
-    const Text3d* rightmost = line.words.back();
-
-    // Case A: left of the whole line — always flush, no impact.
-    if (impact_z > left_edge(leftmost)) {
-        const double flush_z = left_edge(leftmost) + ConfigWall::free_space_dist + new_half_z;
-        // Aimed further LEFT than flush (larger z)? keep it. Else snap flush.
-        const double z = (impact_z > flush_z) ? impact_z : flush_z;
-        return { 0, z };   // front
-    }
-
-    // Case B: right of the whole line — always flush.
-    if (impact_z < right_edge(rightmost)) {
-        const double flush_z = right_edge(rightmost) - ConfigWall::free_space_dist - new_half_z;
-        // Aimed further RIGHT than flush (smaller z)? keep it. Else snap flush.
-        // Never allow larger-than-flush z (that would overlap the neighbour).
-        const double z = (impact_z < flush_z) ? impact_z : flush_z;
-        return { n, z };
-    }
-
-    // Case C: between two words.
-    for (size_t i = 0; i + 1 < n; ++i) {
-        const double gap_left  = right_edge(line.words[i]);
-        const double gap_right = left_edge(line.words[i + 1]);
-        if (impact_z <= gap_left && impact_z >= gap_right) {
-            const double needed = 2.0 * new_half_z + 2.0 * ConfigWall::free_space_dist;
-            const double avail  = gap_left - gap_right;
-            if (avail >= needed)
-                return { i + 1, (gap_left + gap_right) * 0.5 };
-            const double shift = needed - avail;
-            for (size_t k = i + 1; k < n; ++k)
-                line.words[k]->pos.z -= shift;
-            return { i + 1, gap_left - ConfigWall::free_space_dist - new_half_z };
-        }
-    }
-    return { n, right_edge(rightmost) - ConfigWall::free_space_dist - new_half_z };
-}
-
-
-void
-Editor::on_LinePlaced(const DynArray<Text3d*>& words_in, const Vec3& impact)
-{
-    if (words_in.empty()) return;
-
-    // Visual left→right on the NegX wall is decreasing z (left = larger z).
-    DynArray<Text3d*> ws;
-    ws.reserve(words_in.size());
-    for (Text3d* w : words_in) if (w) ws.push_back(w);
-    if (ws.empty()) return;
-    std::sort(ws.begin(), ws.end(),
-        [](const Text3d* a, const Text3d* b){ return a->pos.z > b->pos.z; });
-
-    // Total width of the block: glyph widths + internal gaps.
-    double block_w = 0.0;
-    for (size_t i = 0; i < ws.size(); ++i) {
-        block_w += 2.0 * (double)ws[i]->half_extent;
-        if (i + 1 < ws.size()) block_w += ConfigWall::free_space_dist;
-    }
-
-    Target t       = resolve_Target(impact.y);
-    Line&  targetR = apply_Target(t);
-    Line*  target  = &targetR;
-
-    auto left_edge  = [](const Text3d* w){ return w->pos.z + (double)w->half_extent; };
-    auto right_edge = [](const Text3d* w){ return w->pos.z - (double)w->half_extent; };
-
-    // Insert where the aim points: the slot comes after every word left of the
-    // aim (the wall's left is +z) and before every word to its right.
-    size_t insert_index = 0;
-    for (const Text3d* w : target->words)
-        if (w && w->pos.z > impact.z) ++insert_index;
-
-    const size_t n = target->words.size();
-
-    // Centre the block ON the aim, so it merges where the shot actually landed
-    // rather than flush against whichever word sits left of the gap.
-    double block_left_z = impact.z + block_w * 0.5;   // block's left edge (high-z side)
-
-    // Don't overlap the word to the LEFT of the slot: pull the block right (−z)
-    // if centring would push its left edge into that word.
-    if (insert_index > 0) {
-        const double max_left = right_edge(target->words[insert_index - 1]) - ConfigWall::free_space_dist;
-        if (block_left_z > max_left) block_left_z = max_left;
-    }
-    // Make room on the RIGHT: if the block would overlap the word to its right,
-    // shove that word and everything past it further right (−z).
-    if (insert_index < n) {
-        const double block_right = block_left_z - block_w;
-        const double min_right   = left_edge(target->words[insert_index]) + ConfigWall::free_space_dist;
-        if (block_right < min_right) {
-            const double shift = min_right - block_right;
-            for (size_t k = insert_index; k < n; ++k)
-                target->words[k]->pos.z -= shift;
-        }
-    }
-
-    // Lay the block out left→right from block_left_z (decreasing z) and snap
-    // each word to the row height; keep each word's existing x (wall plane).
-    double cursor = block_left_z;   // left edge for the next glyph
-    for (Text3d* w : ws) {
-        const double hw       = (double)w->half_extent;
-        const double center_z = cursor - hw;
-        w->pos.y = target->y;
-        w->pos.z = center_z;
-        cursor   = center_z - hw - ConfigWall::free_space_dist;
-    }
-
-    // Insert the run into the line in visual order at the chosen slot.
-    for (size_t k = 0; k < ws.size(); ++k)
-        target->words.insert(
-            target->words.begin() + static_cast<std::ptrdiff_t>(insert_index + k),
-            ws[k]);
-}
-
-
-void
-Editor::on_WordRemoved(Text3d* word)
-{
-    if (!word) return;
-
-    for (auto it = _lines.begin(); it != _lines.end(); ) {
-        auto& ws = it->words;
-        auto wit = std::find(ws.begin(), ws.end(), word);
-        if (wit != ws.end()) {
-            ws.erase(wit);
-            if (ws.empty()) {
-                it = _lines.erase(it);
-                return;
-            }
-            return;
-        }
-        ++it;
-    }
-}
-
-
-Vec3
-Editor::on_WordPlaced(Text3d* word, const Vec3& impact_pos)
-{
-    if (!word) return impact_pos;
-
-    Target  t      = resolve_Target(impact_pos.y);
-    Line&   target = apply_Target(t);
-
-    const double new_half_z = word->half_extent;
-    const Placement p = place_OnLine(target, new_half_z, impact_pos.z);
-
-    Vec3 snapped { impact_pos.x, target.y, p.z };
-
-    target.words.insert(
-        target.words.begin() + static_cast<std::ptrdiff_t>(p.insert_index),
-        word);
-
-    return snapped;
-}
-
-
-void
-Editor::add_PlacedLine(const DynArray<Text3d*>& words, double row_y)
-{
-    if (words.empty()) return;
-    Line line;
-    line.y = row_y;
-    for (Text3d* w : words)
-        if (w) line.words.push_back(w);
-    _lines.push_back(std::move(line));
-}
-
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Line numbers
-// ─────────────────────────────────────────────────────────────────────────────
-
-namespace {
-
-Text3dPtr
-make_Number(const std::string& s, const Vec3& pos, float yaw)
-{
-    // The Text3d constructor colours by token lookup (e.g. "1" is a Literal →
-    // red), so force orange afterwards.
-    Text3dPtr t = make_Text3d(s, Text3d::depth_wall,
-                              ConfigWall::line_number_scale, pos,
-                              ConfigColor::orange);
-    t->yaw = yaw;
-    t->set_Color(ConfigColor::orange);
-    return t;
-}
-
-void
-set_NumberText(Text3d* t, const std::string& s)
-{
-    if (t->text == s) return;
-    t->text = s;
-    t->rebuild_Mesh();
-    t->set_Color(ConfigColor::orange);   // rebuild can reset colour; keep orange
-}
-
-} // namespace
-
-
-void
-Editor::update(double dt, const Vec3& eye_pos)
-{
-    if (_lines.empty()) return;
-
-    // Order rows top→bottom (largest y = line 1) to assign numbers.
-    DynArray<size_t> order;
-    order.reserve(_lines.size());
-    for (size_t i = 0; i < _lines.size(); ++i)
-        if (!_lines[i].words.empty()) order.push_back(i);
-
-    std::sort(order.begin(), order.end(),
-        [&](size_t a, size_t b){ return _lines[a].y > _lines[b].y; });
-
-    const Transform tf = get_Transform(Role::Editor, World::get_BoxScale());
-
-    if (order.empty()) return;
-
-    // Global code extents (z) across every row, so both number columns land at
-    // a single aligned position with a clear, uniform gap from the code.
-    double code_left  = -1e18;   // furthest-left z code reaches (visual left = +z)
-    double code_right =  1e18;   // furthest-right z code reaches (visual right = -z)
-    for (size_t oi : order)
-        for (Text3d* w : _lines[oi].words) {
-            if (!w) continue;
-            code_left  = SDL_max(code_left,  w->pos.z + (double)w->half_extent);
-            code_right = SDL_min(code_right, w->pos.z - (double)w->half_extent);
-        }
-
-    // Pass 1: ensure labels exist, refresh digits, advance the bounce, and find
-    // the widest digit so the clamp below can keep every number on the wall.
-    double max_lh = 0.0, max_rh = 0.0;
-    int rank = 1;
-    for (size_t oi : order) {
-        Line& l = _lines[oi];
-
-        const std::string s = std::to_string(rank);
-        if (!l.num_left)  l.num_left  = make_Number(s, Vec3{tf.position.x, l.y, 0.0}, tf.yaw);
-        if (!l.num_right) l.num_right = make_Number(s, Vec3{tf.position.x, l.y, 0.0}, tf.yaw);
-
-        if (l.shown_number != rank) {
-            set_NumberText(l.num_left.get(),  s);
-            set_NumberText(l.num_right.get(), s);
-            l.shown_number = rank;
-        }
-
-        // Damped spring bounce back to rest (shot numbers jiggle, stay put).
-        constexpr double STIFFNESS = 120.0, DAMPING = 12.0;
-        l.bounce_vel += (-STIFFNESS * l.bounce_off - DAMPING * l.bounce_vel) * dt;
-        l.bounce_off += l.bounce_vel * dt;
-
-        max_lh = SDL_max(max_lh, static_cast<double>(l.num_left ->half_extent));
-        max_rh = SDL_max(max_rh, static_cast<double>(l.num_right->half_extent));
-        ++rank;
-    }
-
-    // Code-relative columns, but CLAMPED so a number can never leave the wall.
-    // The wall spans z in [-ws, +ws]; keep a small margin in from each edge.
-    // A left number occupies [left_col_z, left_col_z + 2*lh] (outer edge = +z);
-    // a right number occupies [right_col_z - 2*rh, right_col_z] (outer = -z).
-    const double ws   = World::get_BoxScale();
-    const double EDGE = 0.3;
-    double left_col_z  = code_left  + ConfigWall::line_number_gap;
-    double right_col_z = code_right - ConfigWall::line_number_gap;
-    left_col_z  = SDL_min(left_col_z,   ws - EDGE - 2.0 * max_lh);
-    right_col_z = SDL_max(right_col_z, -ws + EDGE + 2.0 * max_rh);
-
-    // Pass 2: place each number against its column at its (bounced) row height.
-    for (size_t oi : order) {
-        Line& l = _lines[oi];
-
-        // Use a code word's x so the numbers sit on the wall plane at the
-        // same depth as the text.
-        double x = tf.position.x;
-        for (Text3d* w : l.words) { if (w) { x = w->pos.x; break; } }
-
-        const double ny = l.y + l.bounce_off;
-        const double lh = static_cast<double>(l.num_left-> half_extent);
-        const double rh = static_cast<double>(l.num_right->half_extent);
-        l.num_left->pos  = Vec3{ x, ny, left_col_z  + lh };   // code-facing edge on the column
-        l.num_right->pos = Vec3{ x, ny, right_col_z - rh };
-
-        l.num_left->pitch  = Text::tilt_TowardEye(l.num_left->pos,  eye_pos);
-        l.num_right->pitch = Text::tilt_TowardEye(l.num_right->pos, eye_pos);
-    }
-}
-
-
-DynArray<DynArray<Text3d*>>
-Editor::lines_InOrder() const
-{
-    // Order rows top→bottom (largest y first), matching update()'s numbering.
-    DynArray<size_t> order;
-    order.reserve(_lines.size());
-    for (size_t i = 0; i < _lines.size(); ++i)
-        if (!_lines[i].words.empty()) order.push_back(i);
-
-    std::sort(order.begin(), order.end(),
-        [&](size_t a, size_t b){ return _lines[a].y > _lines[b].y; });
-
-    DynArray<DynArray<Text3d*>> out;
-    out.reserve(order.size());
-    for (size_t oi : order) {
-        // Within a row, visual left→right is decreasing z (left = larger z).
-        DynArray<Text3d*> ws;
-        ws.reserve(_lines[oi].words.size());
-        for (Text3d* w : _lines[oi].words)
-            if (w) ws.push_back(w);
-        std::sort(ws.begin(), ws.end(),
-            [](const Text3d* a, const Text3d* b){ return a->pos.z > b->pos.z; });
-        out.push_back(std::move(ws));
-    }
-    return out;
-}
-
-
-DynArray<Editor::NumberHit>
-Editor::number_Hitboxes() const
-{
-    // Build the box live from the label's current pos + half-extents rather
-    // than the base aabb_*() (which can lag a constantly-repositioned object).
-    // Pad generously: a single digit is a small target.
-    constexpr double PAD     = 0.30;
-    constexpr double DEPTH_X = 0.50;   // thickness into the room so shots cross it
-
-    DynArray<NumberHit> out;
-    out.reserve(_lines.size() * 2);
-
-    auto push_box = [&](const Text3d* t, size_t i) {
-        const double hz = static_cast<double>(t->half_extent  ) + PAD;   // along wall (z)
-        const double hy = static_cast<double>(t->half_extent_y) + PAD;   // vertical
-        const Vec3   c  = t->pos;
-        out.push_back({ HIT_BASE + i,
-                        Vec3{ c.x - DEPTH_X, c.y - hy, c.z - hz },
-                        Vec3{ c.x + DEPTH_X, c.y + hy, c.z + hz } });
-    };
-
-    for (size_t i = 0; i < _lines.size(); ++i) {
-        const Line& l = _lines[i];
-        if (l.num_left)  push_box(l.num_left.get(),  i);
-        if (l.num_right) push_box(l.num_right.get(), i);
-    }
-    return out;
-}
-
-
-double
-Editor::scroll_DeltaToEye(size_t line_id, double eye_y) const
-{
-    if (line_id >= _lines.size()) return 0.0;
-    return eye_y - _lines[line_id].y;
-}
-
-
-void
-Editor::bounce(size_t line_id)
-{
-    if (line_id < _lines.size())
-        _lines[line_id].bounce_vel += ConfigWall::bounce_impulse;
-}
-
-
-void
-Editor::draw(GlRenderer& gl, const Camera& cam, const Light& light) const
-{
-    if (_lines.empty()) return;
-
-    DynArray<float> verts;
-    DynArray<int>   indices;
-    verts  .reserve(_lines.size() * 4 * 9);
-    indices.reserve(_lines.size() * 6);
-
-    /*
-    const float ws = World::get_BoxScale();
-    const float x_front = -ws + 0.02f;
-
-    constexpr float nx = 1.0f, ny = 0.0f, nz = 0.0f;
-    constexpr float cr = 0.40f, cg = 0.50f, cb = 0.78f;
-
-    int base = 0;
-    for (const Line& l : _lines) {
-        if (l.words.empty()) continue;
-
-        const float y_mid = static_cast<float>(l.y - ConfigWall::underline_drop);
-        const float y_lo  = y_mid - static_cast<float>(ConfigWall::underline_thick);
-        const float y_hi  = y_mid + static_cast<float>(ConfigWall::underline_thick);
-
-        float pts[4][3] = {
-            { x_front, y_lo, -ws },   // BL
-            { x_front, y_lo,  ws },   // BR
-            { x_front, y_hi,  ws },   // TR
-            { x_front, y_hi, -ws },   // TL
-        };
-
-        for (int v = 0; v < 4; ++v) {
-            verts.push_back(pts[v][0]);
-            verts.push_back(pts[v][1]);
-            verts.push_back(pts[v][2]);
-            verts.push_back(nx); verts.push_back(ny); verts.push_back(nz);
-            verts.push_back(cr); verts.push_back(cg); verts.push_back(cb);
-        }
-
-        indices.push_back(base + 0);
-        indices.push_back(base + 1);
-        indices.push_back(base + 2);
-        indices.push_back(base + 0);
-        indices.push_back(base + 2);
-        indices.push_back(base + 3);
-        base += 4;
-    }
-
-    if (!verts.empty()) {
-        Mat4 view  = buildViewMat4(cam);
-        Mat4 proj  = gl.projection();
-        Mat4 model = Mat4::identity();
-        Mat4 mvp   = Mat4::mul(proj, Mat4::mul(view, model));
-
-        gl.set_Wireframe(false);
-        gl.set_MVP(model, mvp);
-        gl.set_NormalMat(model);
-        gl.set_Light(light);
-        gl.set_ColorUseVertex(true);
-
-        gl.draw_MeshColored(verts.data(),
-                           static_cast<int>(verts.size() / 9),
-                           indices.data(),
-                           static_cast<int>(indices.size()));
-
-        gl.set_ColorUseVertex(false);
-    }
-    */
-
-    // Orange line numbers (own meshes; positioned/numbered in update()).
-    for (const Line& l : _lines) {
-        if (l.num_left)  l.num_left->draw(gl, cam, light);
-        if (l.num_right) l.num_right->draw(gl, cam, light);
-    }
-}
-
-
-double
-Editor::get_GridOrigin() const
-{
-    double origin = 0.0;
-    bool   found  = false;
-    for (const Line& l : _lines) {
-        if (l.words.empty()) continue;
-        if (!found || l.y > origin) { origin = l.y; found = true; }
-    }
-    return found ? origin : 0.0;
-}
-
-double
-Editor::snap_RowY(double y) const
-{
-    const double LH     = ConfigWall::line_height;
-    const double origin = get_GridOrigin();
-    const double row    = std::round((y - origin) / LH);
-    return origin + row * LH;
-}
-
-bool
-Editor::row_IsEmpty(double y) const
-{
-    const double LH     = ConfigWall::line_height;
-    const double slot_y = snap_RowY(y);
-    for (const Line& l : _lines) {
-        if (l.words.empty()) continue;
-        if (SDL_fabs(l.y - slot_y) < LH * 0.5) return false;
-    }
-    return true;
-}
-
-} // namepsace Wall
diff --git a/src/wall/editor.hpp b/src/wall/editor.hpp
deleted file mode 100644
index 915fb42..0000000
--- a/src/wall/editor.hpp
+++ /dev/null
@@ -1,84 +0,0 @@
-#pragma once
-
-#include "text_3d/text_3d.hpp"
-#include "utils/globals.hpp"
-#include "utils/light.hpp"
-#include "wall.hpp"
-
-class Camera;
-class GlRenderer;
-
-namespace Wall {
-
-class Editor
-{
-    struct Line
-    {
-        int    shown_number {-1};
-        double y            {0.0};
-        double bounce_off   {0.0};
-        double bounce_vel   {0.0};
-
-        Text3dPtr num_left;
-        Text3dPtr num_right;
-
-        DynArray<Text3d*>   words;
-    };
-
-    struct Placement
-    {
-        size_t insert_index;
-        double z;
-    };
-
-    struct Target
-    {
-        enum Kind { Merge, Insert } kind;
-        size_t   line_index;
-        double   new_y;
-        double   push_dist {0.0};
-    };
-
-    DynArray<Line> _lines;
-
-    Line*     find_NearestLine (double y);
-    Placement place_OnLine     (Line& line, double new_half_z, double impact_z);
-    double    get_GridOrigin   () const;
-    Target    resolve_Target   (double y) const;
-    Line&     apply_Target     (const Target& t);
-public:
-    static constexpr size_t
-                  HIT_BASE = size_t(1) << 32;
-    static bool   is_LineNumberHit(size_t idx) { return idx >= HIT_BASE; }
-    static size_t line_Of         (size_t idx) { return idx - HIT_BASE; }
-
-
-    struct NumberHit {
-        size_t index;
-        Vec3   aabb_Min;
-        Vec3   aabb_Max;
-    };
-
-    void clear  ();
-    void update (double dt, const Vec3& eye_pos);
-    void draw   (GlRenderer& gl, const Camera& cam, const Light& light) const;
-    void bounce (size_t line_id);
-    void scroll(double delta) { for (Line& l : _lines) l.y += delta; }
-
-    void on_LinePlaced (const DynArray<Text3d*>& words, const Vec3& impact);
-    void on_WordRemoved(Text3d* word);
-    Vec3 on_WordPlaced (Text3d* word, const Vec3& impact_pos);
-    void add_PlacedLine(const DynArray<Text3d*>& words, double row_y);
-
-    bool   row_IsEmpty(double y) const;
-    double snap_RowY  (double y) const;
-    double scroll_DeltaToEye(size_t line_id, double eye_y) const;
-    size_t line_Count       () const { return _lines.size(); }
-    DynArray<DynArray<Text3d*>>
-           lines_InOrder    () const;
-
-    DynArray<NumberHit>
-           number_Hitboxes  () const;
-};
-
-} // namespace Wall
diff --git a/src/wall/label.cpp b/src/wall/label.cpp
index d2e4ee1..4acb157 100644
--- a/src/wall/label.cpp
+++ b/src/wall/label.cpp
@@ -49,9 +49,9 @@ Wall::Label::generate_All(float world_scale)
     const double extra_offset = 2.5f;
 
     // Large scale so the name is readable from across the room.
-    // depth is Text3d::depth_wall * 4 for chunky, visible letters.
+    // depth is Text::Token::depth_wall * 4 for chunky, visible letters.
     const float label_scale = 0.012f;
-    const float label_depth = Text3d::depth_wall * 4.0f;
+    const float label_depth = Text::Token::depth_wall * 4.0f;
 
     for (uint8_t ri = 0; ri < 4; ++ri) {
         Wall::Role      role = static_cast<Wall::Role> (ri);
@@ -109,10 +109,10 @@ Wall::Label::update(double dt, const Camera& camera)
     label.update(dt, camera);
 
     // Tilt up/down toward the player's eye, clamped — identical to the
-    // editor wall text tilt in wall.hpp::tilt_TowardEye. Shared anchor pitch
+    // editor wall text tilt in wall.hpp::tilt_TwardEye. Shared anchor pitch
     // keeps the whole word reading as one rigid plane.
-    const float pitch = Wall::Text::tilt_TowardEye(anchor, camera.pos);
-    for (const Text3dPtr& w : label.get_Words())
+    const float pitch = Wall::tilt_TwardEye(anchor, camera.pos);
+    for (const Text::TokenPtr& w : label.get_Words())
         if (w) w->pitch = pitch;
 }
 
diff --git a/src/wall/label.hpp b/src/wall/label.hpp
index 38d9415..95f186f 100644
--- a/src/wall/label.hpp
+++ b/src/wall/label.hpp
@@ -18,7 +18,7 @@ namespace Wall
         Wall::Role    role;
         float         face_yaw;
         Vec3          anchor;
-        ::Text::Handler label;
+        Text::Handler label;
 
         static DynArray<Label> generate_All(float world_scale);
 
diff --git a/src/wall/text.cpp b/src/wall/text.cpp
deleted file mode 100644
index 6bc906d..0000000
--- a/src/wall/text.cpp
+++ /dev/null
@@ -1,169 +0,0 @@
-#include "entities/world.hpp"
-#include "systems/cpp_highlight_system.hpp"
-#include "text/handler.hpp"
-#include "utils/config.hpp"
-#include "wall/wall.hpp"
-
-
-namespace Wall {
-
-Ptr
-Text::from_Drop(Text3dPtr src)
-{
-    double ws   = World::get_BoxScale<double>();
-    double x = src->pos.x;
-    double z = src->pos.z;
-
-    Vec3& he = src->shape.half_extents;
-
-    double dPX = ws - x;
-    double dNX = ws + x;
-    double dPZ = ws - z;
-    double dNZ = ws + z;
-
-    Wall::Position wall_pos;
-    float yaw;
-
-
-    // he.x is the text WIDTH half-extent, he.z is the text DEPTH (thickness).
-    // After yaw rotation, the depth axis becomes the wall-normal axis.
-    // For X walls the word is rotated 90°, so its local Z (depth) points along
-    // world X — we offset by he.z to sit flush against the wall.
-    // For Z walls the word faces straight on, so local Z (depth) points along
-    // world Z — same he.z offset.
-    double wo = static_cast<double>(ConfigWall::text_offset);
-    if (dPX <= dNX && dPX <= dPZ && dPX <= dNZ) { wall_pos = Wall::Position::PosX; src->pos.x =  ws - he.z - wo; yaw = -SDL_PI_F * 0.5f; }
-    else if (dNX <= dPZ && dNX <= dNZ)          { wall_pos = Wall::Position::NegX; src->pos.x = -ws + he.z + wo; yaw =  SDL_PI_F * 0.5f; }
-    else if (dPZ <= dNZ)                        { wall_pos = Wall::Position::PosZ; src->pos.z =  ws - he.z - wo; yaw =  SDL_PI_F; }
-    else                                        { wall_pos = Wall::Position::NegZ; src->pos.z = -ws + he.z + wo; yaw =  0.0f; }
-
-    src->yaw = yaw;
-
-    Wall::Role role = Wall::role_Of(wall_pos);
-
-    return cpputils::make_unique<Text>(Text{
-        wall_pos,
-        role,
-        std::move(src)
-    });
-}
-
-
-std::string
-Text::extract_Text(const PtrList& texts, Wall::Role role, double line_threshold)
-{
-    DynArray<Text*> items;
-    items.reserve(texts.size());
-
-    for (const Ptr& wt : texts) {
-        if (wt->role == role)
-            items.push_back(wt.get());
-    }
-
-    if (items.empty())
-        return "";
-
-    const Wall::Position wall_pos = Wall::position_Of(role);
-
-    std::sort(items.begin(), items.end(),
-        [](const Text* a, const Text* b) {
-            return a->text3d->pos.y > b->text3d->pos.y;
-        });
-
-    struct Line {
-        DynArray<Text*> items;
-        double sum_y = 0.0;
-    };
-
-    DynArray<Line> lines;
-    lines.reserve(items.size());
-
-    for (Text* wt : items) {
-        const double y = wt->text3d->pos.y;
-
-        if (!lines.empty()) {
-            Line& line = lines.back();
-            const double avg_y = line.sum_y / static_cast<double>(line.items.size());
-
-            if (SDL_fabs(y - avg_y) < line_threshold) {
-                line.items.push_back(wt);
-                line.sum_y += y;
-                continue;
-            }
-        }
-
-        lines.push_back(Line{ {wt}, y });
-    }
-
-    for (Line& line : lines) {
-        std::sort(line.items.begin(), line.items.end(),
-            [&](const Text* a, const Text* b) {
-                if (wall_pos == Wall::Position::PosX ||
-                    wall_pos == Wall::Position::NegX)
-                    return a->text3d->pos.z > b->text3d->pos.z;
-                else
-                    return a->text3d->pos.x > b->text3d->pos.x;
-            });
-    }
-
-    std::string result;
-    for (const Line& line : lines) {
-        for (size_t i = 0; i < line.items.size(); ++i) {
-            if (i) result += ' ';
-
-            const Text* wt = line.items[i];
-            if (wt->text3d->type == ObjectType::Text)
-                result += wt->text3d->text;
-        }
-        result += '\n';
-    }
-
-    return result;
-}
-
-
-void
-Text::draw(GlRenderer& g, const Camera& c, const Light& l) const
-{
-    text3d->draw(g, c, l);
-}
-
-
-void
-Text::show_TokenInfo(::Text::Handler& info, const std::string& token_text)
-{
-    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);
-}
-
-
-float
-Text::tilt_TowardEye(const Vec3& text_pos, const Vec3& eye_pos)
-{
-    double dx = eye_pos.x - text_pos.x;
-    double dy = eye_pos.y - text_pos.y;
-    double dz = eye_pos.z - text_pos.z;
-
-    double horiz = SDL_sqrt(dx*dx + dz*dz);
-    if (horiz < 0.01) return 0.0f;          // player directly above/below — no tilt
-
-    // Sign flipped: we want positive pitch when player is BELOW the text.
-    double pitch = -SDL_atan2(dy, horiz);
-
-    if (pitch >  static_cast<double>(ConfigWall::max_tilt)) pitch =  ConfigWall::max_tilt;
-    if (pitch < -static_cast<double>(ConfigWall::max_tilt)) pitch = -ConfigWall::max_tilt;
-    return static_cast<float>(pitch);
-}
-
-
-} // namespace Wall
diff --git a/src/wall/wall.cpp b/src/wall/wall.cpp
index 2e645f5..3a9eeb1 100644
--- a/src/wall/wall.cpp
+++ b/src/wall/wall.cpp
@@ -1,4 +1,6 @@
+#include "text/token.hpp"
 #include "entities/world.hpp"
+#include "text/handler.hpp"
 #include "wall.hpp"
 
 namespace Wall
@@ -74,4 +76,70 @@ namespace Wall
     {
         return get_Transform(role, world_scale).position;
     }
+
+
+    Drop
+    attach_ToNearestWall(Text::TokenPtr src)
+    {
+        const double ws = World::get_BoxScale<double>();
+        const double x  = src->pos.x;
+        const double z  = src->pos.z;
+
+        Vec3& he = src->shape.half_extents;
+
+        const double dPX = ws - x;
+        const double dNX = ws + x;
+        const double dPZ = ws - z;
+        const double dNZ = ws + z;
+
+        Wall::Position wall_pos;
+        float          yaw;
+
+        const double wo = static_cast<double>(ConfigWall::text_offset);
+        if (dPX <= dNX && dPX <= dPZ && dPX <= dNZ) { wall_pos = Wall::Position::PosX; src->pos.x =  ws - he.z - wo; yaw = -SDL_PI_F * 0.5f; }
+        else if (dNX <= dPZ && dNX <= dNZ)          { wall_pos = Wall::Position::NegX; src->pos.x = -ws + he.z + wo; yaw =  SDL_PI_F * 0.5f; }
+        else if (dPZ <= dNZ)                        { wall_pos = Wall::Position::PosZ; src->pos.z =  ws - he.z - wo; yaw =  SDL_PI_F; }
+        else                                        { wall_pos = Wall::Position::NegZ; src->pos.z = -ws + he.z + wo; yaw =  0.0f; }
+
+        src->yaw = yaw;
+
+        return Drop{ Wall::role_Of(wall_pos), std::move(src) };
+    }
+
+
+    void
+    show_TokenInfo(::Text::Handler& info, const std::string& token_text)
+    {
+        info.clear();
+
+        Text::Token::Type tt = Text::CppHighlight::classify_Word(token_text);
+        Text::Token::Desc d  = Text::Token::get_Description(token_text, tt);
+
+        const std::string line1 = token_text + "  [" + Text::Token::get_TypeName(tt) + "]";
+        const std::string line2 = d.short_desc;
+        const std::string line3 = d.long_desc;
+
+        for (const std::string& line : { line1, line2, line3 })
+            if (!line.empty()) info.append_Line(line);
+    }
+
+
+    float
+    tilt_TwardEye(const Vec3& text_pos, const Vec3& eye_pos)
+    {
+        const double dx = eye_pos.x - text_pos.x;
+        const double dy = eye_pos.y - text_pos.y;
+        const double dz = eye_pos.z - text_pos.z;
+
+        const double horiz = SDL_sqrt(dx*dx + dz*dz);
+        if (horiz < 0.01) return 0.0f;
+
+        double pitch = -SDL_atan2(dy, horiz);
+
+        if (pitch >  static_cast<double>(ConfigWall::max_tilt)) pitch =  ConfigWall::max_tilt;
+        if (pitch < -static_cast<double>(ConfigWall::max_tilt)) pitch = -ConfigWall::max_tilt;
+        return static_cast<float>(pitch);
+    }
+
+
 } // namespace Wall
diff --git a/src/wall/wall.hpp b/src/wall/wall.hpp
index 3f4ca42..0d22e68 100644
--- a/src/wall/wall.hpp
+++ b/src/wall/wall.hpp
@@ -1,6 +1,6 @@
 #pragma once
 
-#include "text_3d/text_3d.hpp"
+#include "text/token.hpp"
 #include "utils/config.hpp"
 #include "utils/globals.hpp"
 
@@ -9,16 +9,25 @@ namespace Text { class Handler; }
 
 namespace Wall
 {
-    struct Text;
-    using Ptr     = cpputils::unique_ptr<Text>;
-    using PtrList = DynArray<Ptr>;
-
     enum class Position : uint8_t { PosX,    NegX,   PosZ,    NegZ     };
     enum class Role     : uint8_t { Program, Editor, Info,    Compiler };
 
-    Role     role_Of      (Position p);
-    Position position_Of  (Role r);
-    Vec3     inward_Normal(Wall::Position pos);
+    class  Surface;
+    struct Drop;
+    struct Label;
+    struct Transform;
+}
+
+namespace Wall
+{
+
+    Role     role_Of       (Position p);
+    Position position_Of   (Role r);
+    Vec3     inward_Normal (Wall::Position pos);
+
+    Drop  attach_ToNearestWall(Text::TokenPtr src);
+    void  show_TokenInfo      (::Text::Handler& info, const std::string& token_text);
+    float tilt_TwardEye       (const Vec3& text_pos, const Vec3& eye_pos);
 
     struct Transform
     {
@@ -36,7 +45,7 @@ namespace Wall
     public:
         struct Quad {
             bool       is_editor;
-            Wall::Role role;
+            Role       role;
             SDL_FColor color;
 
             DynArray<float> verts;
@@ -49,18 +58,11 @@ namespace Wall
         DynArray<Quad> _walls;
     };
 
-    struct Text
+    struct Drop
     {
-        Wall::Position wall_position;
-        Wall::Role     role;
-        Text3dPtr      text3d;
-
-        void draw (GlRenderer& gl, const Camera& camera, const Light& light) const;
-
-        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);
+        Role           role;
+        Text::TokenPtr token;
     };
 
-}
+
+} // namespace Wall