Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / da88be5

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

SHoot in a Straight line AND invert crosshair With Claude

Christian Immanuel · 2026-06-05 06:21 · da88be547aa5b77f6e9db2b5f7cbb5c32d3890b7

 src/entities/pult.cpp             |   2 +-
 src/levels/level/tutorial.cpp     |  24 ++++----
 src/rendering/gl_renderer.cpp     |  94 +++++++++++++++++++++++-----
 src/rendering/gl_renderer.hpp     |   9 +++
 src/states/game_state.cpp         |  97 ++++++++++++++++++++---------
 src/states/game_state.hpp         |  21 ++++---
 src/systems/aim.hpp               |  98 +++++++++++++++++++++++++++++
 src/systems/physics_system.cpp    |  58 +++++++++++++-----
 src/systems/physics_system.hpp    |   3 +-
 src/systems/projectile_system.cpp |  31 ++++++++--
 src/systems/projectile_system.hpp |  30 +++++----
 src/utils/carry_math.cpp          |  12 +++-
 src/utils/carry_math.hpp          |   7 ++-
 src/utils/config.hpp              |   4 ++
 src/wall/wall_label.cpp           | 126 ++++++++++++++++++++++++++++++++++++++
 src/wall/wall_label.hpp           |  51 +++++++++++++++
 16 files changed, 570 insertions(+), 97 deletions(-)

diff --git a/src/entities/pult.cpp b/src/entities/pult.cpp
index 9f6aa96..a785c72 100644
--- a/src/entities/pult.cpp
+++ b/src/entities/pult.cpp
@@ -67,7 +67,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(ConfigColors::color_indigo, Text3d::depth_wall, 0.0025f);
+    label.set_Style(ConfigColors::color_teal, Text3d::depth_wall, 0.0025f);
     label.set_Collidable(true);
     label.attach_To(&pos, Vec3{ 0.0, ConfigPult::label_height, 0.0 });
     label.set_Text(n);
diff --git a/src/levels/level/tutorial.cpp b/src/levels/level/tutorial.cpp
index e6972df..42a8c51 100644
--- a/src/levels/level/tutorial.cpp
+++ b/src/levels/level/tutorial.cpp
@@ -246,24 +246,16 @@ Press BUILD to compile, then walk to the RUN pult and
 press it. Your program runs and its output appears on
 the Program wall — and that's what finishes this step.)",
                 .grants = {
-                    Level::TokenGrant::give("int"),
-                    Level::TokenGrant::give("main"),
-                    Level::TokenGrant::give("("),
-                    Level::TokenGrant::give("void"),
-                    Level::TokenGrant::give(")"),
-                    Level::TokenGrant::give("{"),
+                    Level::TokenGrant::give("#include"),
+                    Level::TokenGrant::give("<stdio.h>"),
                     Level::TokenGrant::give("printf"),
                     Level::TokenGrant::give("("),
                     Level::TokenGrant::give("\"hello\""),
                     Level::TokenGrant::give(")"),
                     Level::TokenGrant::give(";"),
-                    Level::TokenGrant::give("return"),
-                    Level::TokenGrant::give("0"),
-                    Level::TokenGrant::give(";"),
-                    Level::TokenGrant::give("}"),
                 },
                 .scene        = {},
-                .reset_carry  = true,
+                .reset_carry  = false,
                 .goal = AllOf{{
                     CompilePattern{ "int main ( void ) { printf ( \"hello\" ) ; return 0 ; }" },
                     PressButton{ PultAction::RunProgram },
@@ -283,11 +275,19 @@ the Program wall — and that's what finishes this step.)",
 
 Have a play, then jump onto the plate to continue.)",
                 .grants  = {
+                    Level::TokenGrant::give("scanf(\"%d\", &x)"),
                     Level::TokenGrant::give("int"),
                     Level::TokenGrant::give("x"),
                     Level::TokenGrant::give("="),
                     Level::TokenGrant::give("0"),
                     Level::TokenGrant::give(";"),
+                    Level::TokenGrant::give(";"),
+                    Level::TokenGrant::give(";"),
+                    Level::TokenGrant::give("\"Your Number is : %d\""),
+                    Level::TokenGrant::give(","),
+                    Level::TokenGrant::give("x"),
+                    Level::TokenGrant::give("42"),
+                    Level::TokenGrant::give("printf(\"Enter a Number:\\n\\t\")"),
                 },
                 .scene   = Scene{
                     .words  = {},
@@ -299,7 +299,7 @@ Have a play, then jump onto the plate to continue.)",
                     },
                     .ai_seeds  = {},
                 },
-                .reset_carry  = true,
+                .reset_carry  = false,
                 .goal         = ReachPlate{ "" },
                 .max_ai_words = 0,
             },
diff --git a/src/rendering/gl_renderer.cpp b/src/rendering/gl_renderer.cpp
index 0b761c9..0e149d3 100644
--- a/src/rendering/gl_renderer.cpp
+++ b/src/rendering/gl_renderer.cpp
@@ -30,6 +30,12 @@ void main()
 }
 )GLSL";
 
+static const char* kCrosshairFragSrc = R"GLSL(
+#version 330 core
+out vec4 FragColor;
+void main() { FragColor = vec4(1.0, 1.0, 1.0, 1.0); }
+)GLSL";
+
 
 static const char* kExtractFragSrc = R"GLSL(
 #version 330 core
@@ -505,6 +511,24 @@ bool GlRenderer::init(SDL_Window* window)
     create_ExtractProgram_();
     create_BlurProgram_();
     create_CompositeProgram_();
+
+    {   // crosshair: reuse the blit vertex shader (vec2 aPos @ loc 0)
+        GLuint v = compileShader(GL_VERTEX_SHADER,   kBlitVertSrc);
+        GLuint f = compileShader(GL_FRAGMENT_SHADER, kCrosshairFragSrc);
+        _crosshair_prog = glCreateProgram();
+        glAttachShader(_crosshair_prog, v);
+        glAttachShader(_crosshair_prog, f);
+        glLinkProgram (_crosshair_prog);
+        glDeleteShader(v); glDeleteShader(f);
+
+        glGenVertexArrays(1, &_crosshair_vao);
+        glGenBuffers     (1, &_crosshair_vbo);
+        glBindVertexArray(_crosshair_vao);
+        glBindBuffer     (GL_ARRAY_BUFFER, _crosshair_vbo);
+        glVertexAttribPointer    (0, 2, GL_FLOAT, GL_FALSE, 2*sizeof(float), (void*)0);
+        glEnableVertexAttribArray(0);
+        glBindVertexArray(0);
+    }
  
     // Lookup the new emissive uniforms on the main program.
     _uEmissiveColor    = glGetUniformLocation(_prog, "u_EmissiveColor");
@@ -574,22 +598,21 @@ GlRenderer::end_Frame(SDL_Window* window)
     // Run bloom passes (extract + blur).
     { FP_ZONE("end_Frame.bloom");
         run_BloomPipeline_();
-        glFinish();   // TEMPORARY: force GPU drain so the zone captures real GPU cost
-    }
 
-    // Composite scene + bloom to screen.
-    glBindFramebuffer(GL_FRAMEBUFFER, 0);
-    glViewport(0, 0, _view_w, _view_h);
-    glDisable(GL_DEPTH_TEST);
+        // Composite scene + bloom to screen.
+        glBindFramebuffer(GL_FRAMEBUFFER, 0);
+        glViewport(0, 0, _view_w, _view_h);
+        glDisable(GL_DEPTH_TEST);
 
-    glUseProgram   (_composite_prog);
-    glActiveTexture(GL_TEXTURE0);
-    glBindTexture  (GL_TEXTURE_2D, _scene_color_tex);
-    glUniform1i    (_uComposeScene, 0);
-    glActiveTexture(GL_TEXTURE1);
-    glBindTexture  (GL_TEXTURE_2D, _blur_tex[1]);   // final bloom
-    glUniform1i    (_uComposeBloom, 1);
-    glUniform1f    (_uComposeStrength, _bloom_strength);
+        glUseProgram   (_composite_prog);
+        glActiveTexture(GL_TEXTURE0);
+        glBindTexture  (GL_TEXTURE_2D, _scene_color_tex);
+        glUniform1i    (_uComposeScene, 0);
+        glActiveTexture(GL_TEXTURE1);
+        glBindTexture  (GL_TEXTURE_2D, _blur_tex[1]);   // final bloom
+        glUniform1i    (_uComposeBloom, 1);
+        glUniform1f    (_uComposeStrength, _bloom_strength);
+    }
 
     { FP_ZONE("end_Frame.composite");
         glBindVertexArray(_fullscreen_vao);
@@ -601,6 +624,8 @@ GlRenderer::end_Frame(SDL_Window* window)
         glUseProgram(_prog);
     }
 
+    draw_Crosshair_();
+
     { FP_ZONE("end_Frame.swap");
         SDL_GL_SwapWindow(window);
     }
@@ -695,6 +720,47 @@ GlRenderer::set_FaceShade(float r, float g, float b, float back_dim, bool use_fr
     glUniform1i(_uUseFront,  use_front ? 1 : 0);
 }
 
+
+void
+GlRenderer::draw_Crosshair_()
+{
+    if (!_crosshair_enabled || _view_w <= 0 || _view_h <= 0) return;
+
+    // Pixel dimensions → NDC half-extents (per-axis, so it stays square).
+    const float gap = 4.0f, len = 9.0f, thick = 1.5f;
+    const float gx = gap   / static_cast<float>(_view_w) * 2.0f, gy = gap   / static_cast<float>(_view_h) * 2.0f;
+    const float lx = len   / static_cast<float>(_view_w) * 2.0f, ly = len   / static_cast<float>(_view_h) * 2.0f;
+    const float tx = thick / static_cast<float>(_view_w) * 2.0f, ty = thick / static_cast<float>(_view_h) * 2.0f;
+
+    // Four arms, center gap, NO overlap (overlap would invert twice = no-op).
+    float v[48];
+    int   k = 0;
+    auto quad = [&](float x0, float y0, float x1, float y1) {
+        v[k++]=x0; v[k++]=y0;  v[k++]=x1; v[k++]=y0;  v[k++]=x1; v[k++]=y1;
+        v[k++]=x0; v[k++]=y0;  v[k++]=x1; v[k++]=y1;  v[k++]=x0; v[k++]=y1;
+    };
+    quad(  gx,        -ty,   gx + lx,  ty);          // right
+    quad(-(gx + lx),  -ty,  -gx,       ty);          // left
+    quad( -tx,         gy,   tx,       gy + ly);     // up
+    quad( -tx,      -(gy + ly), tx,   -gy);          // down
+
+    glDisable  (GL_DEPTH_TEST);
+    glEnable   (GL_BLEND);
+    glBlendFunc(GL_ONE_MINUS_DST_COLOR, GL_ZERO);    // out = (1 - dst) → invert
+
+    glUseProgram(_crosshair_prog);
+    glBindVertexArray(_crosshair_vao);
+    glBindBuffer (GL_ARRAY_BUFFER, _crosshair_vbo);
+    glBufferData (GL_ARRAY_BUFFER, sizeof(v), v, GL_STREAM_DRAW);
+    glDrawArrays (GL_TRIANGLES, 0, 24);
+    glBindVertexArray(0);
+
+    glDisable   (GL_BLEND);
+    glEnable    (GL_DEPTH_TEST);
+    glUseProgram(_prog);
+}
+
+
 // ─────────────────────────────────────────────────────────────────────────────
 // draw_Mesh / draw_Lines
 // ─────────────────────────────────────────────────────────────────────────────
diff --git a/src/rendering/gl_renderer.hpp b/src/rendering/gl_renderer.hpp
index 0694851..465dc99 100644
--- a/src/rendering/gl_renderer.hpp
+++ b/src/rendering/gl_renderer.hpp
@@ -109,6 +109,12 @@ class GlRenderer {
     int    _blur_w          {0};
     int    _blur_h          {0};
 
+    // Crosshair: tiny screen-space overlay, inverts the pixels behind it.
+    GLuint _crosshair_prog {0};
+    GLuint _crosshair_vao  {0};
+    GLuint _crosshair_vbo  {0};
+    bool   _crosshair_enabled {true};
+
     // Programs
     GLuint _extract_prog    {0};
     GLuint _blur_prog       {0};
@@ -141,6 +147,8 @@ class GlRenderer {
     void create_CompositeProgram_();
     void run_BloomPipeline_();
 
+    void draw_Crosshair_();
+
     GLuint compileShader(GLenum type, const char* src);
     bool   linkProgram(GLuint vert, GLuint frag);
 public:
@@ -160,6 +168,7 @@ public:
     void set_TextLineSpacing(float m);
     void set_Emissive  (float r, float g, float b, float strength);
     void set_BloomStrength  (float s) { _bloom_strength = s; }
+    void set_Crosshair(bool on) { _crosshair_enabled = on; }
 
     void enable_MandelbrotMode(bool on);
     void enable_WallPaperMode (bool on);
diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index b9e66da..6562fb2 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -10,6 +10,7 @@
 #include "game_state.hpp"
 #include "levels/level_check.hpp"
 #include "rendering/gl_renderer.hpp"
+#include "systems/aim.hpp"
 #include "systems/celebration_system.hpp"
 #include "systems/game_mod_abi.h"
 #include "systems/input_system.hpp"
@@ -46,6 +47,8 @@ GameState::GameState(size_t starting_level)
     _floor_sys       = cpputils::make_unique<FloorMandelbrot>  ();
     _floor_sys->generate(_ctx.world->scale(), -_ctx.world->scale());
 
+    _wall_labels     = WallLabel::generate_All(_ctx.world->scale());
+
     _word_ai_sys     = cpputils::make_unique<WordAiSystem>();
     _word_ai_sys->set_WorldScale(_ctx.world->scale());
 
@@ -294,8 +297,11 @@ GameState::on_ShootOne()
 
     DynArray<Text3dPtr> single;
     single.push_back(std::move(word));
-    _projectiles_sys->launch(_projectiles, std::move(single),
-                             _ctx.camera->fwd, _ctx.camera->pos);
+
+    Aim::AimResult aim = Aim::crosshair_Target(*_ctx.camera, _ctx.words, _ctx.scenery,
+                                               _ctx.wall_texts, _ctx.world->scale());
+    _projectiles_sys->launch(_projectiles, std::move(single /* or all_words */),
+                             _ctx.camera->fwd, _ctx.camera->pos, aim);
 
     if (!_carried.empty() && _carried.last && _carried.last->word)
         TextWall::show_TokenInfo(_ctx.wall_texts,
@@ -324,9 +330,12 @@ GameState::on_ShootAll()
             all_words.push_back(std::move(node->word));
 
     _carried = CarriedLine();
-    if (!all_words.empty())
+    if (!all_words.empty()) {
+        Aim::AimResult aim = Aim::crosshair_Target(*_ctx.camera, _ctx.words, _ctx.scenery,
+                                                   _ctx.wall_texts, _ctx.world->scale());
         _projectiles_sys->launch(_projectiles, std::move(all_words),
-                                 _ctx.camera->fwd, _ctx.camera->pos);
+                                 _ctx.camera->fwd, _ctx.camera->pos, aim);
+    }
 
     infowall_StepdetailsShow();
 
@@ -443,7 +452,7 @@ GameState::on_TextCancel(FrameInput& input)
 
 
 void
-GameState::program_FeedInput(ProjectileResult& result)
+GameState::projectile_FeedProgramInput(ProjectileResult& result)
 {
     std::string line;
     for (const Text3dPtr& w : result.words) {
@@ -535,6 +544,28 @@ GameState::projectile_LandOnWall(ProjectileResult& result)
 }
 
 
+void
+GameState::projectile_LandOnWallText(ProjectileResult& result,
+                                      DynArray<size_t>& wall_text_to_erase)
+{
+    if (EditorWall::is_LineNumberHit(result.wall_text_index)) {
+        const size_t lid   = EditorWall::line_Of(result.wall_text_index);
+        const double delta = _editor_wall.scroll_DeltaToEye(lid, _ctx.camera->pos.y);
+
+        for (TextWallPtr& wt : _ctx.wall_texts)
+            if (wt && wt->text3d && wt->role == Wall::Role::Editor)
+                wt->text3d->pos.y += delta;
+        _editor_wall.scroll(delta);
+        _editor_wall.bounce(lid);
+
+        projectile_LandOnFloor(result);
+    } else {
+        walltext_HandleShootDown(result, wall_text_to_erase);
+    }
+    return;
+}
+
+
 void
 GameState::walltext_HandleShootDown(ProjectileResult& result,
                                   DynArray<size_t>& to_erase)
@@ -650,6 +681,9 @@ GameState::update(double dt)
             wt->text3d->pitch = Wall::tilt_TowardEye(wt->text3d->pos, _ctx.camera->pos);
         }
 
+        for (WallLabel& lbl : _wall_labels)
+            lbl.update(dt, *_ctx.camera);
+
         carriedline_Update();
 
         const double floor_y = -static_cast<double>(_ctx.world->scale());
@@ -737,6 +771,11 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
             wt->draw(renderer, *_ctx.camera, *_ctx.light);
     }
 
+    { FP_ZONE("render.wall_labels");
+        for (const WallLabel& lbl : _wall_labels)
+            lbl.draw(renderer, *_ctx.camera, *_ctx.light);
+    }
+
     { FP_ZONE("render._carried");
         DynArray<CarriedWordNode*> nodes = _carried.nodes();
         for (const CarriedWordNode* node : nodes)
@@ -945,7 +984,7 @@ GameState::substep(double dt)
     }
 
     { FP_ZONE("subst.phys_projectiles");
-        _physics_sys->update_Projectiles(_projectiles, dt);
+        _physics_sys->update_Projectiles(_projectiles, dt, _ctx.words);
     }
 
     DynArray<ProjectileContactEvent> projectile_contacts;
@@ -957,6 +996,8 @@ GameState::substep(double dt)
             lbl->update(dt, *_ctx.camera);
 
         all_labels.push_back(&_hint_sys->get_Label());
+        for (WallLabel& wl : _wall_labels)
+            all_labels.push_back(&wl.label);
     }
 
     { FP_ZONE("subst.resolve_projectiles");
@@ -976,11 +1017,26 @@ GameState::substep(double dt)
                     i
                 });
         }
-        // Line numbers live on the editor wall but outside wall_texts; expose
-        // their hitboxes here so they can be shot (tagged with an encoded id).
+
+        // Hit Line Number
         for (const EditorWall::NumberHit& nh : _editor_wall.number_Hitboxes())
             wall_text_hits.push_back({ Wall::Position::NegX,
                                        nh.aabb_min, nh.aabb_max, nh.index });
+
+        // Hit Wall Label
+        for (size_t li = 0; li < _wall_labels.size(); ++li) {
+            const DynArray<Text3dPtr>& words = _wall_labels[li].label.words();
+            for (size_t wi = 0; wi < words.size(); ++wi) {
+                if (!words[wi]) continue;
+                constexpr size_t LABEL_HIT_BASE = size_t(1) << 48;
+                wall_text_hits.push_back({
+                        Wall::position_of(_wall_labels[li].role),
+                        words[wi]->aabb_min(),
+                        words[wi]->aabb_max(),
+                        LABEL_HIT_BASE | (li << 16) | wi
+                        });
+            }
+        }
     }
 
     DynArray<ProjectileResult> proj_results;
@@ -995,28 +1051,13 @@ GameState::substep(double dt)
     { FP_ZONE("subst.projectile_land");
         for (ProjectileResult& result : proj_results) {
             switch (result.landing) {
-                case ProjectileLanding::Floor:    projectile_LandOnFloor(result); break;
+                case ProjectileLanding::Floor:    projectile_LandOnFloor     (result); break;
                 case ProjectileLanding::ProgramInput:
-                                                  program_FeedInput(result);      break;
-                case ProjectileLanding::Wall:     projectile_LandOnWall(result);  break;
+                                                  projectile_FeedProgramInput(result); break;
+                case ProjectileLanding::Wall:     projectile_LandOnWall      (result); break;
                 case ProjectileLanding::WallText:
-                    if (EditorWall::is_LineNumberHit(result.wall_text_index)) {
-                        const size_t lid   = EditorWall::line_Of(result.wall_text_index);
-                        const double delta = _editor_wall.scroll_DeltaToEye(lid, _ctx.camera->pos.y);
-
-                        // Move the editor code AND the wall's own line records
-                        // together so they stay in sync; numbers reflow next update.
-                        for (TextWallPtr& wt : _ctx.wall_texts)
-                            if (wt && wt->text3d && wt->role == Wall::Role::Editor)
-                                wt->text3d->pos.y += delta;
-                        _editor_wall.scroll(delta);
-                        _editor_wall.bounce(lid);
-
-                        projectile_LandOnFloor(result);  // shot tokens drop; number stays put
-                    } else {
-                        walltext_HandleShootDown(result, wall_text_to_erase);
-                    }
-                    break;
+                                                  projectile_LandOnWallText  (result, wall_text_to_erase);
+                                                  break;
                 case ProjectileLanding::None:     break;
             }
         }
diff --git a/src/states/game_state.hpp b/src/states/game_state.hpp
index b9b0e9e..8a0adf0 100644
--- a/src/states/game_state.hpp
+++ b/src/states/game_state.hpp
@@ -29,6 +29,7 @@
 #include "utils/globals.hpp"
 #include "wall/editor_wall.hpp"
 #include "wall/text_wall.hpp"
+#include "wall/wall_label.hpp"
 
 
 struct StepLedger {
@@ -71,16 +72,18 @@ class GameState : public IState
     double _accumulator    { 0.0 };
     double _alpha          { 0.0 };
 
-    DynArray<Text3d*> _typing_words;
-    std::string       _typing_last;
+    DynArray<WallLabel> _wall_labels;
+
+    DynArray<Text3d*>   _typing_words;
+    std::string         _typing_last;
 
     DynArray<Projectile>    _projectiles;
     DynArray<WordAnim_Add>  _anims_add;
     DynArray<WordAnim_Drop> _anims_drop;
 
-    DynArray<GameObject*> _mod_scenery;
-    DynArray<GameObject*> _step_scenery;
-    DynArray<StepPlate>   _step_plates;
+    DynArray<GameObject*>   _mod_scenery;
+    DynArray<GameObject*>   _step_scenery;
+    DynArray<StepPlate>     _step_plates;
 
     cpputils::unique_ptr<InputSystem>      _input_sys;
     cpputils::unique_ptr<PhysicsSystem>    _physics_sys;
@@ -137,10 +140,10 @@ class GameState : public IState
     void infowall_StepdetailsShow ();
     void walltext_HandleShootDown (ProjectileResult& result, DynArray<size_t>& to_erase);
 
-    void program_FeedInput(ProjectileResult& result);
-
-    void projectile_LandOnFloor   (ProjectileResult& result);
-    void projectile_LandOnWall    (ProjectileResult& result);
+    void projectile_FeedProgramInput(ProjectileResult&);
+    void projectile_LandOnFloor     (ProjectileResult&);
+    void projectile_LandOnWall      (ProjectileResult&);
+    void projectile_LandOnWallText  (ProjectileResult&, DynArray<size_t>& wall_text_to_erase);
 
     void pult_DispatchAction (PultAction act);
 
diff --git a/src/systems/aim.hpp b/src/systems/aim.hpp
new file mode 100644
index 0000000..deb2b59
--- /dev/null
+++ b/src/systems/aim.hpp
@@ -0,0 +1,98 @@
+#pragma once
+
+#include <SDL3/SDL.h>
+
+#include "entities/camera.hpp"
+#include "entities/game_object.hpp"
+#include "systems/collision.hpp"
+#include "text_3d/text_3d.hpp"
+#include "utils/globals.hpp"
+#include "utils/math.hpp"
+#include "wall/text_wall.hpp"
+
+// Aim::crosshair_Target — the first surface the crosshair ray meets.
+//
+//   .point  world aim point (always valid: wall / floor / object hit)
+//   .track  non-null ONLY when a movable word (from `words`) was hit — that
+//           object should be hard-tracked so the throw follows it.
+//   .local  the aim point expressed in the target's local frame (yaw-derotated)
+//           so it rides the word's rotation: a hit on the right edge stays on
+//           the right edge as the word spins.
+//
+// Scenery, walls, floor, and stuck wall-text are treated as STATIC points
+// (track == nullptr): they don't move, and keeping them out of tracking avoids
+// the differing-lifetime list problem (wall_texts live in a separate vector).
+namespace Aim
+{
+
+struct AimResult {
+    Vec3    point {};
+    Text3d* track { nullptr };
+    Vec3    local {};
+};
+
+inline AimResult
+crosshair_Target(const Camera& camera,
+                 const DynArray<Text3dPtr>&     words,
+                 const DynArray<GameObjectPtr>& scenery,
+                 const DynArray<TextWallPtr>&   wall_texts,
+                 float world_scale)
+{
+    const Vec3   eye = camera.pos;
+    const Vec3   dir = camera.fwd;                 // unit
+    const double ws  = static_cast<double>(world_scale);
+
+    // 1. Distance along the ray to the box surface (nearest bounding plane).
+    double wall_t = 1e18;
+    auto plane = [&](double o, double d) {
+        if (SDL_fabs(d) < 1e-9) return;
+        const double t = SDL_max((ws - o) / d, (-ws - o) / d);
+        if (t > 0.0 && t < wall_t) wall_t = t;
+    };
+    plane(eye.x, dir.x);
+    plane(eye.y, dir.y);
+    plane(eye.z, dir.z);
+    if (wall_t > 1e17) wall_t = ws * 2.0;
+
+    const Vec3 seg { dir.x * wall_t, dir.y * wall_t, dir.z * wall_t };
+
+    AimResult out;
+    out.point   = Vec3{ eye.x + seg.x, eye.y + seg.y, eye.z + seg.z };   // wall point
+    double best_t = 1.0;                                                 // seg-fraction
+    Text3d* win   = nullptr;                                             // winning word, if any
+
+    auto test = [&](const Vec3& mn, const Vec3& mx, Text3d* obj) {
+        Collision::SweptResult sw = Collision::swept_RayAabb(eye, seg, mn, mx);
+        if (sw.hit && sw.t < best_t) {
+            best_t    = sw.t;
+            out.point = { eye.x + seg.x * sw.t,
+                          eye.y + seg.y * sw.t,
+                          eye.z + seg.z * sw.t };
+            win = obj;                  // word → track it; non-word → nullptr
+        }
+    };
+
+    for (const Text3dPtr& 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 TextWallPtr& wt : wall_texts)
+        if (wt && wt->text3d) test(wt->text3d->aabb_min(), wt->text3d->aabb_max(), nullptr);
+
+    // If a movable word won, record it and de-rotate the offset into its frame.
+    if (win) {
+        out.track = win;
+        const double dx = out.point.x - win->pos.x;
+        const double dy = out.point.y - win->pos.y;
+        const double dz = out.point.z - win->pos.z;
+        const double cy = SDL_cos(win->yaw);
+        const double sy = SDL_sin(win->yaw);
+        out.local = { cy * dx - sy * dz,        // R(-yaw) * (point - pos)
+                      dy,
+                      sy * dx + cy * dz };
+    }
+
+    return out;
+}
+
+}  // namespace Aim
diff --git a/src/systems/physics_system.cpp b/src/systems/physics_system.cpp
index 9817f12..6457086 100644
--- a/src/systems/physics_system.cpp
+++ b/src/systems/physics_system.cpp
@@ -282,27 +282,53 @@ PhysicsSystem::resolve_WorldBounds(GameObject& obj, float world_scale)
 /// Projectile
 
 void
-PhysicsSystem::update_Projectiles(DynArray<Projectile>& projectiles, double dt)
+PhysicsSystem::update_Projectiles(DynArray<Projectile>& projectiles, double dt,
+                                  const DynArray<Text3dPtr>& words)
 {
     for (Projectile& p : projectiles) {
         if (p.sticking)      continue;
         if (p.words.empty()) continue;
 
-        p.step_dt = dt;
-
+        p.step_dt       = dt;
         p.prev_centroid = Text3d::words_centroid(p.words);
 
-        // Speed-scaled gravity
-        double speed = length(p.vel);
-        double grav_scale = 1.0 + speed * 0.02;
-        p.vel.y -= ConfigPhysics::proj_grav * grav_scale * dt;
-
-        // Exponential horizontal drag
-        double drag_factor  = SDL_pow(ConfigPhysics::air_drag, dt * 60.0);
-        p.vel.x *= drag_factor;
-        p.vel.z *= drag_factor;
+        // ── Hard-track: steer straight at the target's live point, no gravity.
+        if (p.track_obj) {
+            // Validate the target is still alive (could be picked up, shot
+            // down, consumed, or destroyed on teardown). Never deref a raw
+            // pointer we haven't re-confirmed this frame.
+            bool alive = false;
+            for (const Text3dPtr& w : words)
+                if (w.get() == p.track_obj) { alive = true; break; }
+
+            if (alive) {
+                Text3d* o  = p.track_obj;
+                const double cy = SDL_cos(o->yaw), sy = SDL_sin(o->yaw);
+                const Vec3 live {                      // pos + R(yaw)*local
+                    o->pos.x + cy * p.track_local.x + sy * p.track_local.z,
+                    o->pos.y + p.track_local.y,
+                    o->pos.z - sy * p.track_local.x + cy * p.track_local.z
+                };
+                p.track_last = live;
+
+                const Vec3   c  = p.prev_centroid;
+                Vec3 d { live.x - c.x, live.y - c.y, live.z - c.z };
+                const double L = length(d);
+                if (L > 1e-6) {
+                    const double s = ConfigPhysics::proj_throw_speed / L;
+                    p.vel = Vec3{ d.x * s, d.y * s, d.z * s };
+                }
+                // No gravity while tracking — fly dead-on to the live point.
+            } else {
+                // Target gone: drop tracking, fall toward last known point
+                // under gravity like a normal thrown word.
+                p.track_obj = nullptr;
+                p.vel.y -= ConfigPhysics::proj_grav * dt;
+            }
+        } else {
+            p.vel.y -= ConfigPhysics::proj_grav * dt;
+        }
 
-        // Move all words
         for (Text3dPtr& w : p.words) {
             if (!w) continue;
             w->pos.x += p.vel.x * dt;
@@ -400,6 +426,8 @@ PhysicsSystem::resolve_Projectiles(DynArray<Projectile>& projectiles,
                     if (resolve_SingleBoxCollision(proj, obj->aabb_min(), obj->aabb_max(),
                                 word_he, RESTITUTION_TEXT,
                                 cur, prev, dir, sw)) {
+                        proj.collided = true;
+                        if (obj == proj.track_obj) proj.track_obj = nullptr;
                         obj->body.apply_KickImpulse({
                                 vel_before.x * 0.4,
                                 SDL_fabs(vel_before.y) * 0.1 + 0.3,
@@ -422,6 +450,7 @@ PhysicsSystem::resolve_Projectiles(DynArray<Projectile>& projectiles,
                 if (resolve_SingleBoxCollision(proj, obj->aabb_min(), obj->aabb_max(),
                                word_he, RESTITUTION_STATIC,
                                cur, prev, dir, sw)) {
+                    proj.collided = true;
                     if (contacts) {
                         contacts->push_back(ProjectileContactEvent{
                                 .projectile = &proj, .other = obj,
@@ -434,7 +463,7 @@ PhysicsSystem::resolve_Projectiles(DynArray<Projectile>& projectiles,
 
         // 2. Resolve against Text Labels
         if (labels) {
-            constexpr double RESTITUTION_LABEL = 0.55;
+            constexpr double RESTITUTION_LABEL = 0.55; // WTF is that
 
             for (TextLabel* label : *labels) {
                 if (!label || label->empty()) continue;
@@ -450,6 +479,7 @@ PhysicsSystem::resolve_Projectiles(DynArray<Projectile>& projectiles,
                     if (resolve_SingleBoxCollision(proj, hw->aabb_min(), hw->aabb_max(),
                                word_he, RESTITUTION_LABEL, cur, prev, dir, sw)) {
                         // Handle unique Label side-effect
+                        proj.collided = true;
                         label->kick(i, vel_before, sw.normal);
                     }
                 }
diff --git a/src/systems/physics_system.hpp b/src/systems/physics_system.hpp
index e919f3b..5ec9b97 100644
--- a/src/systems/physics_system.hpp
+++ b/src/systems/physics_system.hpp
@@ -45,8 +45,9 @@ public:
     void update_Player(Player& player, double acc_x, double acc_z, double dt);
     void update_Objects(const DynArray<GameObject*>& objects,
                         double dt, double gravity, float world_scale);
-    void update_Projectiles(DynArray<Projectile>& projectiles, double dt);
 
+    void update_Projectiles(DynArray<Projectile>& projectiles, double dt,
+                            const DynArray<Text3dPtr>& words);
     void resolve_Projectiles(DynArray<Projectile>& projectiles,
                              const DynArray<GameObject*>* statics,
                              const DynArray<TextLabel*>* labels,
diff --git a/src/systems/projectile_system.cpp b/src/systems/projectile_system.cpp
index 24fe12e..44fbc78 100644
--- a/src/systems/projectile_system.cpp
+++ b/src/systems/projectile_system.cpp
@@ -267,6 +267,7 @@ begin_EditorStick(Projectile& p, Wall::Position hit_pos)
             c_y   + w->ribbon_local.y,
             0.0
         };
+        w->pos.y = w->ribbon_local.y;
     }
 }
 
@@ -321,6 +322,7 @@ bounce_OffWall(Projectile& p, Wall::Position hit_pos, double ws,
     }
 
     p.bounce_state |= axis_bit;
+    p.collided = true;
 
     if (x_axis) {
         p.vel.x = bounce_velocity(p.vel.x, 0.70);
@@ -385,9 +387,10 @@ handle_WallHit(Projectile& p, const Vec3& centroid, Wall::Position hit_pos,
 
 void
 ProjectileSystem::launch(DynArray<Projectile>&   out,
-                         DynArray<Text3dPtr> words,
-                         const Vec3& fwd,
-                         const Vec3& eye)
+                DynArray<Text3dPtr> words,
+                const Vec3& fwd,
+                const Vec3& eye,
+                const Aim::AimResult& aim)
 {
     if (words.empty()) return;
 
@@ -418,8 +421,20 @@ ProjectileSystem::launch(DynArray<Projectile>&   out,
         };
     }
 
+    // ── Ballistic solve (method b): pick a flight time from horizontal
+    // distance, then solve the launch velocity so the centroid passes through
+    // `target` under constant gravity. Always one solution; lands on aim.
+    const Vec3   target = aim.point;
+    const double g     = ConfigPhysics::proj_grav;
+    const double dx    = target.x - centroid.x;
+    const double dy    = target.y - centroid.y;
+    const double dz    = target.z - centroid.z;
+    const double horiz = SDL_sqrt(dx*dx + dz*dz);
+    double T = horiz / ConfigPhysics::proj_throw_speed;
+    T = SDL_clamp(T, ConfigPhysics::proj_t_min, ConfigPhysics::proj_t_max);
+
     Projectile proj;
-    proj.vel           = fwd * ConfigPhysics::shoot_speed;
+    proj.vel           = Vec3{ dx / T, dy / T + 0.5 * g * T, dz / T };
     proj.yaw           = yaw;
     proj.bounce_state  = 0;
     proj.words         = std::move(words);
@@ -427,6 +442,14 @@ ProjectileSystem::launch(DynArray<Projectile>&   out,
     proj.launch_origin = centroid;
     proj.cam_origin    = eye;
     proj.prev_centroid = centroid;
+
+    // Hard-track a movable word: steer to its live point until contact.
+    if (aim.track) {
+        proj.track_obj   = aim.track;
+        proj.track_local = aim.local;
+        proj.track_last  = aim.point;
+    }
+
     out.push_back(std::move(proj));
 }
 
diff --git a/src/systems/projectile_system.hpp b/src/systems/projectile_system.hpp
index a12bce0..01e54c7 100644
--- a/src/systems/projectile_system.hpp
+++ b/src/systems/projectile_system.hpp
@@ -2,6 +2,7 @@
 
 #include <cstdint>
 
+#include "systems/aim.hpp"
 #include "text_3d/text_3d.hpp"
 #include "utils/globals.hpp"
 #include "utils/math.hpp"
@@ -16,14 +17,20 @@ struct Projectile {
     Vec3     dir{};
     Vec3     prev_centroid{};
     Vec3     launch_origin{};
-    Vec3     cam_origin{};      // camera eye at launch — the crosshair ray origin
+    Vec3     cam_origin   {}; // camera eye at launch — the crosshair ray origin
     double   yaw;
-    uint8_t        bounce_state = 0;
-    bool           sticking     = false;
+    uint8_t        bounce_state  = 0;
+    bool           sticking      = false;
+    bool           collided      = false;   // hit something → gravity resumes
     bool           program_stick = false;   // sticking to the Program wall as input echo
-    double         stick_t      = 0.0;
-    Wall::Position stick_wall   { Wall::Position::PosX };
-    double         step_dt      = 0.0;
+    double         stick_t       = 0.0;
+    Wall::Position stick_wall    { Wall::Position::PosX };
+    double         step_dt       = 0.0;
+
+    Text3d* track_obj    = nullptr;   // hard-track target; null = ballistic/free
+    Vec3    track_local  {};          // aim point in target's local frame
+    Vec3    track_last   {};          // last known world point (fallback if obj dies)
+
 
     Vec3  ribbon_origin        {};
     Vec3  ribbon_start_origin  {};
@@ -48,11 +55,11 @@ enum class ProjectileLanding : uint8_t {
 };
 
 struct ProjectileResult {
-    ProjectileLanding      landing  = ProjectileLanding::None;
-    Wall::Position         wall_pos = Wall::Position::NegZ;
-    size_t                 wall_text_index = 0;
+    ProjectileLanding   landing  = ProjectileLanding::None;
+    Wall::Position      wall_pos = Wall::Position::NegZ;
+    size_t              wall_text_index = 0;
     DynArray<Text3dPtr> words;
-    Vec3                   vel{};
+    Vec3                vel{};
 };
 
 
@@ -61,7 +68,8 @@ public:
     void launch(DynArray<Projectile>& out,
                 DynArray<Text3dPtr> words,
                 const Vec3& fwd,
-                const Vec3& eye);
+                const Vec3& eye,
+                const Aim::AimResult& aim);
 
     void update(DynArray<Projectile>&        projectiles,
                 DynArray<ProjectileResult>&  results,
diff --git a/src/utils/carry_math.cpp b/src/utils/carry_math.cpp
index ca58122..234eac6 100644
--- a/src/utils/carry_math.cpp
+++ b/src/utils/carry_math.cpp
@@ -6,10 +6,18 @@
 Vec3
 CarryMath::line_Anchor(const Camera& camera, double carry_dist)
 {
+    // Right-hand carry pose. Camera has no `up`, so derive it: with `right`
+    // horizontal and `fwd` unit, cross(right, fwd) is the unit up vector.
+    const Vec3& right = camera.right;
+    const Vec3  up    = cross(camera.right, camera.fwd);
+
     return {
-        camera.pos.x + camera.fwd.x * carry_dist,
-        camera.pos.y + camera.fwd.y * carry_dist,
+        camera.pos.x + camera.fwd.x * carry_dist
+                     + right.x * CARRY_HAND_RIGHT - up.x * CARRY_HAND_DOWN,
+        camera.pos.y + camera.fwd.y * carry_dist
+                     + right.y * CARRY_HAND_RIGHT - up.y * CARRY_HAND_DOWN,
         camera.pos.z + camera.fwd.z * carry_dist
+                     + right.z * CARRY_HAND_RIGHT - up.z * CARRY_HAND_DOWN
     };
 }
 
diff --git a/src/utils/carry_math.hpp b/src/utils/carry_math.hpp
index 46667ea..2630ec0 100644
--- a/src/utils/carry_math.hpp
+++ b/src/utils/carry_math.hpp
@@ -13,6 +13,12 @@ namespace CarryMath
     static constexpr double CARRY_DIST = 1.2;
     static constexpr double CARRY_GAP  = 0.18;
 
+    // Hold the carried line in the player's right hand: the anchor (where the
+    // most-recently-picked word sits) is pushed off the crosshair to the lower
+    // right. The rest of the line trails left from there, unchanged.
+    static constexpr double CARRY_HAND_RIGHT = 0.45;   // toward the right hand
+    static constexpr double CARRY_HAND_DOWN  = 0.35;   // below the crosshair
+
 
     Vec3   line_Anchor(const Camera& camera, double carry_dist);
 
@@ -21,4 +27,3 @@ namespace CarryMath
                                      const Vec3& right,
                                      double carry_gap);
 }
-
diff --git a/src/utils/config.hpp b/src/utils/config.hpp
index 43705b3..0a2658a 100644
--- a/src/utils/config.hpp
+++ b/src/utils/config.hpp
@@ -20,6 +20,10 @@ static constexpr double stick_dist  =  0.2;
 
 static constexpr double player_jump_velocity = 8.4375;
 static constexpr double player_move_accel    = 60.0;
+
+static constexpr double proj_throw_speed = 18.0;
+static constexpr double proj_t_min       = 0.18;  // clamp: nearest throws
+static constexpr double proj_t_max       = 1.2;   // clamp: farthest throws
 }
 
 
diff --git a/src/wall/wall_label.cpp b/src/wall/wall_label.cpp
new file mode 100644
index 0000000..75a40bb
--- /dev/null
+++ b/src/wall/wall_label.cpp
@@ -0,0 +1,126 @@
+#include "wall_label.hpp"
+
+#include "entities/camera.hpp"
+#include "rendering/gl_renderer.hpp"
+#include "utils/config.hpp"
+#include "wall/wall.hpp"
+
+// ── Label appearance ──────────────────────────────────────────────────────────
+
+namespace {
+
+static const char* label_Name(Wall::Role r)
+{
+    switch (r) {
+        case Wall::Role::Program:  return "Program";
+        case Wall::Role::Editor:   return "Editor";
+        case Wall::Role::Info:     return "Info";
+        case Wall::Role::Compiler: return "Compiler";
+    }
+    return "";
+}
+
+// Per-role tint — echoes the wall surface colors from wall_surfaces.cpp.
+static SDL_FColor label_Color(Wall::Role r)
+{
+    switch (r) {
+        case Wall::Role::Program:  return { 0.95f, 0.92f, 0.82f, 1.0f }; // warm ivory
+        case Wall::Role::Editor:   return { 0.55f, 0.72f, 0.92f, 1.0f }; // soft blue
+        case Wall::Role::Info:     return { 0.80f, 0.90f, 1.00f, 1.0f }; // pale sky
+        case Wall::Role::Compiler: return { 0.70f, 0.65f, 1.00f, 1.0f }; // light indigo
+    }
+    return ConfigColors::color_white;
+}
+
+} // namespace
+
+
+// ── generate_All ─────────────────────────────────────────────────────────────
+
+DynArray<WallLabel>
+WallLabel::generate_All(float world_scale)
+{
+    DynArray<WallLabel> labels;
+    labels.reserve(4);
+
+    const float label_y = -world_scale * 0.68f;
+
+    // Pull slightly further from the wall so it floats in front of wall text.
+    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.
+    const float label_scale = 0.012f;
+    const float label_depth = Text3d::depth_wall * 4.0f;
+
+    for (uint8_t ri = 0; ri < 4; ++ri) {
+        Wall::Role      role = static_cast<Wall::Role> (ri);
+        Wall::Position  pos  = Wall::position_of       (role);
+        Wall::Transform tf   = Wall::get_Transform     (role, world_scale);
+
+        Vec3 anch = tf.position;
+        anch.y = static_cast<double>(label_y);
+
+        switch (pos) {
+            case Wall::Position::PosX: anch.x -= extra_offset; break;
+            case Wall::Position::NegX: anch.x += extra_offset; break;
+            case Wall::Position::PosZ: anch.z -= extra_offset; break;
+            case Wall::Position::NegZ: anch.z += extra_offset; break;
+        }
+
+        WallLabel wl;
+        wl.role     = role;
+        wl.anchor   = anch;
+        wl.face_yaw = tf.yaw;
+
+        wl.label.set_Style      (label_Color(role), label_depth, label_scale);
+        wl.label.set_Billboard  (false);
+        wl.label.set_FaceYaw    (tf.yaw);
+        wl.label.set_Collidable (true);
+        wl.label.set_Centre     (anch);
+        wl.label.set_Text       (label_Name(role));
+
+        labels.push_back        (std::move(wl));
+    }
+
+    return labels;
+}
+
+
+// ── on_Hit ───────────────────────────────────────────────────────────────────
+
+void
+WallLabel::on_Hit(size_t word_index, const Vec3& projectile_vel)
+{
+    const Vec3 normal = wall_InwardNormal(Wall::position_of(role));
+    label.kick(word_index, projectile_vel, normal);
+}
+
+
+// ── update ───────────────────────────────────────────────────────────────────
+
+void
+WallLabel::update(double dt, const Camera& camera)
+{
+    // Positions + jiggle spring first. In non-billboard mode TextLabel::update
+    // forces every word's pitch to 0, so we must apply our eye-tilt AFTER it,
+    // not before — otherwise the tilt gets clobbered. Pitch doesn't affect the
+    // positions update() computes, so ordering it last is safe.
+    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
+    // keeps the whole word reading as one rigid plane.
+    const float pitch = Wall::tilt_TowardEye(anchor, camera.pos);
+    for (const Text3dPtr& w : label.words())
+        if (w) w->pitch = pitch;
+}
+
+
+// ── draw ─────────────────────────────────────────────────────────────────────
+
+void
+WallLabel::draw(GlRenderer& gl, const Camera& camera, const Light& light) const
+{
+    label.draw(gl, camera, light);
+}
diff --git a/src/wall/wall_label.hpp b/src/wall/wall_label.hpp
new file mode 100644
index 0000000..8d5a994
--- /dev/null
+++ b/src/wall/wall_label.hpp
@@ -0,0 +1,51 @@
+#pragma once
+
+#include "text_3d/text_label.hpp"
+#include "utils/globals.hpp"
+#include "utils/light.hpp"
+#include "wall/wall.hpp"
+
+class GlRenderer;
+class Camera;
+
+// WallLabel: a large name-plate hovering in front of each wall at a height
+// where the player has to look up slightly. Shooting it jiggles the words
+// via TextLabel::kick() but they spring back to their anchor position.
+//
+// Orientation: fixed yaw (faces the room, same as wall text), pitch tilts
+// up/down toward the player's eye each frame — identical to the editor
+// wall text behaviour.
+struct WallLabel
+{
+    Wall::Role  role;
+    TextLabel   label;
+    Vec3        anchor;    // world-space centre (never changes after generate())
+    float       face_yaw;  // fixed yaw matching the wall's orientation
+
+    // Factory: creates one label per wall role, positioned at the given scale.
+    static DynArray<WallLabel>
+    generate_All(float world_scale);
+
+    // Call when a projectile hits word at word_index with the given velocity.
+    // The push direction is the wall's own inward normal (derived from role),
+    // so the caller doesn't need a per-hit surface normal.
+    void on_Hit(size_t word_index, const Vec3& projectile_vel);
+
+    // Per-frame: tilt label toward eye and update jiggle spring.
+    void update(double dt, const Camera& camera);
+
+    void draw(GlRenderer& gl, const Camera& camera, const Light& light) const;
+};
+
+// Free helper: the inward-facing surface normal for a wall position (points
+// from the wall into the room). kick() uses this as the push direction.
+inline Vec3 wall_InwardNormal(Wall::Position pos)
+{
+    switch (pos) {
+        case Wall::Position::PosX: return { -1.0, 0.0,  0.0 };
+        case Wall::Position::NegX: return {  1.0, 0.0,  0.0 };
+        case Wall::Position::PosZ: return {  0.0, 0.0, -1.0 };
+        case Wall::Position::NegZ: return {  0.0, 0.0,  1.0 };
+    }
+    return { 0.0, 0.0, 1.0 };
+}