Renders a live directional vector representing an entity’s true movement through space. The vector’s direction reflects actual velocity, not facing, while its length and opacity scale with speed. This makes inertia, drift, braking, and course correction immediately legible without UI text.

A short secondary heading vector can optionally display facing direction, allowing direct visual comparison between where an entity is pointed and where it is actually traveling—critical for validating spaceflight physics, AI intent, and player control feel.

Core Concepts Demonstrated

Why It Matters
This POC becomes a foundational diagnostic and gameplay signal. It enables players to read motion instinctively and allows developers to tune thrust, damping, and steering with immediate feedback. Nearly every advanced movement or combat system downstream depends on this clarity.


Code for Above

<div style="max-width:1500px;margin:0 auto;">
  <iframe
    title="Velocity Vector Visualization POC"
    scrolling="no"
    style="display:block;margin:0 auto;border:0;width:1500px;height:1500px;overflow:hidden;border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.55);background:#000;"
    sandbox="allow-scripts allow-same-origin"
    srcdoc='<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Velocity Vector Visualization POC</title>
<style>
  :root { color-scheme: dark; }
  html, body { margin:0; padding:0; width:100%; height:100%; overflow:hidden; background:#000; font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif; }
  #app { width:1500px; height:1500px; margin:0 auto; position:relative; background:#000; }
  canvas { display:block; width:1500px; height:1500px; }

  /* Title + Description block */
  .top {
    position:absolute; left:14px; top:14px; right:14px;
    display:flex; flex-direction:column; gap:6px;
    pointer-events:none;
  }
  .title {
    display:inline-flex; width:max-content;
    padding:8px 12px;
    border-radius:12px;
    background:rgba(0,0,0,.55);
    border:1px solid rgba(255,255,255,.10);
    box-shadow:0 10px 30px rgba(0,0,0,.35);
    font-weight:650;
    letter-spacing:.02em;
  }
  .desc {
    max-width:980px;
    padding:8px 12px;
    border-radius:12px;
    background:rgba(0,0,0,.45);
    border:1px solid rgba(255,255,255,.08);
    color:rgba(255,255,255,.78);
    font-size:12px;
    line-height:1.35;
  }

  /* Minimal HUD */
  .hud {
    position:absolute; left:0; right:0; bottom:0;
    height:92px; padding:10px 14px;
    background:linear-gradient(to top, rgba(0,0,0,.88), rgba(0,0,0,.35));
    border-top:1px solid rgba(255,255,255,.08);
    display:flex; gap:14px; align-items:flex-start; justify-content:space-between;
    pointer-events:none;
  }
  .hud .left, .hud .right { display:flex; flex-direction:column; gap:6px; }
  .hud .row { display:flex; gap:10px; align-items:baseline; }
  .k { opacity:.7; font-size:12px; letter-spacing:.02em; }
  .v { font-size:14px; }
  .hint { opacity:.55; font-size:12px; }
  .pill {
    display:inline-flex; align-items:center; gap:8px;
    padding:6px 10px; border:1px solid rgba(255,255,255,.10);
    border-radius:999px; background:rgba(255,255,255,.03);
  }
</style>
</head>
<body>
  <div id="app">
    <canvas id="c" width="1500" height="1500"></canvas>

    <div class="top">
      <div class="title">Velocity Vector Visualization POC</div>
      <div class="desc">
        Renders a prominent vector arrow showing <b>true velocity</b> (direction of travel), not facing.
        Arrow length and intensity scale with speed so drift and braking are immediately legible.
        Optional heading vector shows facing direction for side-by-side comparison.
      </div>
    </div>

    <div class="hud">
      <div class="left">
        <div class="row">
          <span class="pill"><span class="k">Controls</span><span class="v">↑ thrust · ←/→ rotate · Space brake · R reset · H toggle heading</span></span>
        </div>
        <div class="hint">Goal: you should be able to read motion instantly from the arrow alone.</div>
      </div>
      <div class="right">
        <div class="row"><span class="k">Speed</span><span class="v" id="speed">0.00</span></div>
        <div class="row"><span class="k">Heading</span><span class="v" id="heading">0°</span></div>
        <div class="row"><span class="k">Vel Dir</span><span class="v" id="veldir">0°</span></div>
      </div>
    </div>
  </div>

<script>
(() => {
  const W = 1500, H = 1500;
  const canvas = document.getElementById("c");
  const ctx = canvas.getContext("2d");

  const uiSpeed = document.getElementById("speed");
  const uiHeading = document.getElementById("heading");
  const uiVelDir = document.getElementById("veldir");

  // ======== Input ========
  const keys = new Set();
  let showHeading = true;

  addEventListener("keydown", (e) => {
    if (["ArrowUp","ArrowLeft","ArrowRight","Space","KeyR","KeyH"].includes(e.code)) e.preventDefault();
    keys.add(e.code);
    if (e.code === "KeyR") reset();
    if (e.code === "KeyH") showHeading = !showHeading;
  }, { passive:false });

  addEventListener("keyup", (e) => keys.delete(e.code));

  // ======== World (stars + faint grid) ========
  const stars = [];
  for (let i=0; i<520; i++){
    const big = Math.random() < 0.08;
    stars.push({
      x: Math.random()*W,
      y: Math.random()*H,
      r: big ? (2.0 + Math.random()*1.4) : (0.9 + Math.random()*0.8),
      a: big ? (0.35 + Math.random()*0.35) : (0.12 + Math.random()*0.35)
    });
  }

  // ======== Ship state ========
  const ship = {
    x: W/2, y: H/2,
    vx: 0, vy: 0,
    angle: -Math.PI/2,
    angVel: 0
  };

  function reset(){
    ship.x = W/2; ship.y = H/2;
    ship.vx = 0; ship.vy = 0;
    ship.angle = -Math.PI/2;
    ship.angVel = 0;
  }

  // ======== Tuning (slower accel) ========
  const TURN_ACCEL = 0.0085;
  const TURN_DAMP  = 0.92;

  const THRUST     = 0.070;   // slower forward accel (was 0.11)
  const LIN_DAMP   = 0.996;   // slightly less damping (keeps spacey feel)
  const BRAKE      = 0.90;
  const MAX_SPEED  = 18;

  function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
  function len(x,y){ return Math.hypot(x,y); }
  function radToDeg(r){ return (r * 180/Math.PI); }
  function normRad(a){
    while (a <= -Math.PI) a += Math.PI*2;
    while (a >  Math.PI) a -= Math.PI*2;
    return a;
  }

  // ======== Rendering ========
  function drawGrid(){
    ctx.save();
    ctx.globalAlpha = 0.14;
    ctx.lineWidth = 1;

    for (let x=0; x<=W; x+=150){
      ctx.strokeStyle = "rgba(255,255,255,0.10)";
      ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke();
    }
    for (let y=0; y<=H; y+=150){
      ctx.strokeStyle = "rgba(255,255,255,0.10)";
      ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke();
    }

    ctx.globalAlpha = 0.09;
    for (let x=0; x<=W; x+=50){
      if (x%150===0) continue;
      ctx.strokeStyle = "rgba(255,255,255,0.07)";
      ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke();
    }
    for (let y=0; y<=H; y+=50){
      if (y%150===0) continue;
      ctx.strokeStyle = "rgba(255,255,255,0.07)";
      ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke();
    }
    ctx.restore();
  }

  function drawStars(){
    ctx.save();
    ctx.fillStyle = "#fff";
    for (const s of stars){
      ctx.globalAlpha = s.a;
      ctx.beginPath();
      ctx.arc(s.x, s.y, s.r, 0, Math.PI*2);
      ctx.fill();
    }
    ctx.restore();
  }

  function drawShip(){
    ctx.save();
    ctx.translate(ship.x, ship.y);
    ctx.rotate(ship.angle);

    ctx.globalAlpha = 0.95;
    ctx.fillStyle = "rgba(255,255,255,0.92)";
    ctx.strokeStyle = "rgba(0,0,0,0.70)";
    ctx.lineWidth = 2;

    ctx.beginPath();
    ctx.moveTo(24, 0);
    ctx.lineTo(-16, 13);
    ctx.lineTo(-10, 0);
    ctx.lineTo(-16, -13);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();

    // engine dot
    ctx.globalAlpha = 0.75;
    ctx.fillStyle = "rgba(0,0,0,0.65)";
    ctx.beginPath(); ctx.arc(-12, 0, 3, 0, Math.PI*2); ctx.fill();

    // thrust indicator
    if (keys.has("ArrowUp")){
      ctx.globalAlpha = 0.70;
      ctx.strokeStyle = "rgba(255,255,255,0.60)";
      ctx.lineWidth = 3;
      ctx.beginPath();
      ctx.moveTo(-18, 0);
      ctx.lineTo(-36, 0);
      ctx.stroke();
    }

    ctx.restore();
  }

  // ======== Prominent velocity vector ========
  function drawVelocityVector(){
    const speed = len(ship.vx, ship.vy);

    // Always show a "stub" so you can see direction immediately when barely moving
    const stub = 34;

    let nx = 0, ny = 0;
    if (speed >= 0.0001){
      nx = ship.vx / speed;
      ny = ship.vy / speed;
    }

    // Length scaling: stronger + longer, and never below stub when moving
    const L = (speed < 0.02) ? 0 : clamp(stub + speed * 38, 44, 720);

    // Intensity scaling: more aggressive so it pops
    const a = (speed < 0.02) ? 0.0 : clamp(0.45 + speed / 10, 0.55, 0.98);

    // If stopped, draw a prominent stop marker
    if (speed < 0.02){
      ctx.save();
      ctx.globalAlpha = 0.55;
      ctx.strokeStyle = "rgba(255,255,255,0.55)";
      ctx.lineWidth = 2;
      ctx.beginPath(); ctx.arc(ship.x, ship.y, 8, 0, Math.PI*2); ctx.stroke();
      ctx.globalAlpha = 0.35;
      ctx.beginPath(); ctx.arc(ship.x, ship.y, 2, 0, Math.PI*2); ctx.fillStyle="rgba(255,255,255,0.55)"; ctx.fill();
      ctx.restore();
      return;
    }

    const x2 = ship.x + nx * L;
    const y2 = ship.y + ny * L;

    // Under-glow beam
    ctx.save();
    ctx.globalAlpha = a * 0.55;
    ctx.lineWidth = 14;
    ctx.lineCap = "round";
    ctx.strokeStyle = "rgba(255,255,255,0.35)";
    ctx.beginPath();
    ctx.moveTo(ship.x, ship.y);
    ctx.lineTo(x2, y2);
    ctx.stroke();
    ctx.restore();

    // Main shaft
    ctx.save();
    ctx.globalAlpha = a;
    ctx.lineWidth = 5;
    ctx.lineCap = "round";
    ctx.strokeStyle = "rgba(255,255,255,0.92)";
    ctx.beginPath();
    ctx.moveTo(ship.x, ship.y);
    ctx.lineTo(x2, y2);
    ctx.stroke();

    // Arrow head (bigger)
    const head = 24;
    const ang = Math.atan2(ny, nx);

    ctx.fillStyle = "rgba(255,255,255,0.92)";
    ctx.beginPath();
    ctx.moveTo(x2, y2);
    ctx.lineTo(x2 - Math.cos(ang - 0.52) * head, y2 - Math.sin(ang - 0.52) * head);
    ctx.lineTo(x2 - Math.cos(ang + 0.52) * head, y2 - Math.sin(ang + 0.52) * head);
    ctx.closePath();
    ctx.fill();

    // Speed ticks (more visible)
    ctx.globalAlpha = a * 0.70;
    ctx.lineWidth = 3;
    const ticks = clamp(Math.floor(speed * 0.9), 2, 12);
    for (let i=1; i<=ticks; i++){
      const t = i/(ticks+1);
      const tx = ship.x + nx * L * t;
      const ty = ship.y + ny * L * t;
      const px = -ny, py = nx;
      const tickLen = 10;
      ctx.beginPath();
      ctx.moveTo(tx - px*tickLen, ty - py*tickLen);
      ctx.lineTo(tx + px*tickLen, ty + py*tickLen);
      ctx.stroke();
    }

    ctx.restore();

    // Tail cap dot so origin is obvious
    ctx.save();
    ctx.globalAlpha = a;
    ctx.fillStyle = "rgba(255,255,255,0.92)";
    ctx.beginPath(); ctx.arc(ship.x, ship.y, 4, 0, Math.PI*2); ctx.fill();
    ctx.restore();
  }

  function drawHeadingVector(){
    if (!showHeading) return;
    ctx.save();
    ctx.globalAlpha = 0.30;
    ctx.lineWidth = 3;
    ctx.strokeStyle = "rgba(255,255,255,0.55)";
    const L = 110;
    const x2 = ship.x + Math.cos(ship.angle) * L;
    const y2 = ship.y + Math.sin(ship.angle) * L;
    ctx.beginPath();
    ctx.moveTo(ship.x, ship.y);
    ctx.lineTo(x2, y2);
    ctx.stroke();

    // tiny head marker
    ctx.globalAlpha = 0.25;
    ctx.fillStyle = "rgba(255,255,255,0.55)";
    ctx.beginPath(); ctx.arc(x2, y2, 3, 0, Math.PI*2); ctx.fill();
    ctx.restore();
  }

  function wrap(){
    if (ship.x < -60) ship.x = W+60;
    if (ship.x > W+60) ship.x = -60;
    if (ship.y < -60) ship.y = H+60;
    if (ship.y > H+60) ship.y = -60;
  }

  // ======== Loop ========
  let last = performance.now();
  function frame(now){
    const dt = Math.min(32, now-last) / 16.6667;
    last = now;

    // rotate
    if (keys.has("ArrowLeft"))  ship.angVel -= TURN_ACCEL * dt;
    if (keys.has("ArrowRight")) ship.angVel += TURN_ACCEL * dt;
    ship.angVel *= Math.pow(TURN_DAMP, dt);
    ship.angle = normRad(ship.angle + ship.angVel * dt);

    // thrust
    if (keys.has("ArrowUp")){
      ship.vx += Math.cos(ship.angle) * THRUST * dt;
      ship.vy += Math.sin(ship.angle) * THRUST * dt;
    }

    // brake
    if (keys.has("Space")){
      ship.vx *= Math.pow(BRAKE, dt);
      ship.vy *= Math.pow(BRAKE, dt);
    }

    // cap speed
    const sp = len(ship.vx, ship.vy);
    if (sp > MAX_SPEED){
      ship.vx = (ship.vx/sp) * MAX_SPEED;
      ship.vy = (ship.vy/sp) * MAX_SPEED;
    }

    // slight damping
    ship.vx *= Math.pow(LIN_DAMP, dt);
    ship.vy *= Math.pow(LIN_DAMP, dt);

    // integrate
    ship.x += ship.vx * dt * 4;
    ship.y += ship.vy * dt * 4;
    wrap();

    // render
    ctx.clearRect(0,0,W,H);
    drawStars();
    drawGrid();

    // vectors first
    drawVelocityVector();
    drawHeadingVector();

    drawShip();

    // HUD numbers
    const speed = len(ship.vx, ship.vy);
    uiSpeed.textContent = speed.toFixed(2);

    const headingDeg = (radToDeg(ship.angle) + 360) % 360;
    uiHeading.textContent = headingDeg.toFixed(0) + "°";

    const velAng = (radToDeg(Math.atan2(ship.vy, ship.vx)) + 360) % 360;
    uiVelDir.textContent = (speed < 0.02 ? "—" : velAng.toFixed(0) + "°");

    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>'>
  </iframe>
</div>