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
- Velocity vs orientation separation
- Inertial drift visualization
- Speed-scaled signal strength
- Physics debugging without HUD clutter
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>