Driving with Victor.js and CreateJS

The second piece in the museum's niche: a little triangle you can drive around a grey canvas. It looks like almost nothing — but the entire "physics engine" is a single velocity vector, which makes it a nice first encounter with how games move things. The annotated source is below.

  • Hold W to accelerate.
  • Press A and D to steer.
  • Hold S to brake.
  • Crashing into a wall costs you most of your speed.

How it works

One vector is the whole simulation. The ship owns a velocity vector, and every frame ends with position += velocity. Everything you feel while driving is just different ways of changing that one vector before the add.

The nose is just the angle of the velocity. The triangle is drawn pointing along the x-axis, so orienting it takes one line: rotation = velocity.horizontalAngleDeg() — which is nothing but atan2(y, x) in degrees. And because friction only ever shrinks the velocity without zeroing it, the nose keeps its last heading when you coast to a stop.

Thrust pushes along the heading. accelerate() normalizes the velocity to a unit direction, scales it, and adds it back — so W always pushes the way you are already moving. (Victor.js normalizes a zero vector to (1, 0), which is why the ship sets off to the right from a standstill.)

Steering rotates the velocity itself. A and D call rotateDeg(±3) on the velocity — the nose you see is derived from the motion, not stored separately. You steer the motion, like a drifting car, and steering does nothing while standing still.

Friction is interpolation. Both braking and rolling resistance use mix() — a linear interpolation of the velocity toward zero. Removing a fixed percentage of the remaining speed each frame gives exponential decay, which is why coasting to a stop feels natural.

A bounce is a sign flip. Hitting a wall inverts the velocity component that points into it, then divides both components unevenly — most of the speed into the wall is destroyed, some sideways speed survives. Five lines of code, surprisingly believable crashes.

The code

One file, no build step. Victor.js provides the vector math, CreateJS (EaselJS) draws the triangle.

main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
/*
 * Driving with Victor.js — steering a triangle with nothing but 2D vector math.
 *
 * The entire "physics engine" is one vector: the velocity. Every frame we
 * nudge it (thrust, brake, steering, friction, wall bounces) and then add
 * it to the ship's position. That one idea — position += velocity, velocity
 * += forces — is the core of almost every physics simulation.
 *
 * Victor.js provides the vector type; EaselJS draws the ship.
 */

"use strict";

/* ------------------------------------------------------------------ *
 * CONFIG — all per-frame amounts (the game runs at 60 frames/second).
 * ------------------------------------------------------------------ */
const THRUST = 0.07;          // forward push while W is held
const BRAKE = 0.022;          // how hard S eases the velocity toward zero
const FRICTION = 0.016;       // always-on decay, so the ship coasts to a stop
const TURN_DEGREES = 3;       // A/D rotate the velocity by this much
const EDGE = 10;              // bounce when the ship's center gets this close
                              // to a wall (the triangle is ~8 px long)
const BOUNCE_LOSS_HIT = 5;    // crash divisor on the axis that hit the wall
const BOUNCE_LOSS_SIDE = 1.5; // crash divisor on the other axis

let stage;
const keysDown = new Set();   // physical keys currently held (by e.code)

/* ------------------------------------------------------------------ *
 * THE VEHICLE — a drawn triangle plus a velocity vector and the rules
 * that change it. Reading update() top to bottom is reading the physics.
 * ------------------------------------------------------------------ */
function createVehicle(x, y) {
  // A triangle drawn around its own origin, nose pointing toward +x.
  // Drawing it nose-along-x means "rotation = angle of the velocity"
  // orients it correctly with no extra math.
  const ship = new createjs.Shape();
  ship.graphics.beginFill("#ffe082")
    .moveTo(8, 0)       // nose
    .lineTo(-6, -5)     // back left
    .lineTo(-6, 5)      // back right
    .closePath();
  ship.x = x;
  ship.y = y;

  return {
    ship: ship,
    velocity: new Victor(0, 0),

    /* Thrust acts along the current heading: normalize the velocity to a
     * unit direction, scale it by THRUST, add it back on. One quirk worth
     * knowing: Victor normalizes the zero vector to (1, 0) — which is why
     * the ship sets off to the right from a standstill. */
    accelerate() {
      this.velocity.add(
        this.velocity.clone().normalize().multiply(new Victor(THRUST, THRUST)));
    },

    /* mix() is linear interpolation toward another vector. Easing toward
     * (0, 0) removes a fixed percentage of the remaining speed each frame —
     * exponential decay, which feels exactly like rolling resistance. */
    slow(amount) {
      this.velocity.mix(new Victor(0, 0), amount);
    },

    update() {
      // 1. Controls. Note that steering rotates the VELOCITY, not a nose
      //    direction — you steer the motion itself, like a drifting car,
      //    and steering does nothing while standing still.
      if (keysDown.has("KeyW")) this.accelerate();
      if (keysDown.has("KeyS")) this.slow(BRAKE);
      if (keysDown.has("KeyA")) this.velocity.rotateDeg(-TURN_DEGREES);
      if (keysDown.has("KeyD")) this.velocity.rotateDeg(TURN_DEGREES);

      // 2. Walls. A bounce is just flipping the velocity component that
      //    points into the wall — checked so we only flip when actually
      //    moving toward it. The asymmetric divide makes crashes lossy:
      //    most of the speed into the wall is gone, some sideways remains.
      const w = stage.canvas.width;
      const h = stage.canvas.height;
      if ((this.ship.x <= EDGE && this.velocity.x < 0) ||
          (this.ship.x >= w - EDGE && this.velocity.x > 0)) {
        this.velocity.invertX();
        this.velocity.divide(new Victor(BOUNCE_LOSS_HIT, BOUNCE_LOSS_SIDE));
      }
      if ((this.ship.y <= EDGE && this.velocity.y < 0) ||
          (this.ship.y >= h - EDGE && this.velocity.y > 0)) {
        this.velocity.invertY();
        this.velocity.divide(new Victor(BOUNCE_LOSS_SIDE, BOUNCE_LOSS_HIT));
      }

      // 3. Friction, always.
      this.slow(FRICTION);

      // 4. Move, and point the nose along the motion. The "heading" is
      //    nothing more than atan2 of the velocity — and because friction
      //    only ever shrinks the velocity without zeroing it, the nose
      //    keeps its last direction when the ship coasts to a stop.
      this.ship.x += this.velocity.x;
      this.ship.y += this.velocity.y;
      this.ship.rotation = this.velocity.horizontalAngleDeg();
    },
  };
}

/* ------------------------------------------------------------------ *
 * SETUP — track held keys in a Set (so several can be down at once),
 * and run the vehicle + redraw every tick.
 * ------------------------------------------------------------------ */
function init() {
  stage = new createjs.Stage("demoCanvas");

  const car = createVehicle(20, 20);
  stage.addChild(car.ship);

  document.addEventListener("keydown", function (e) { keysDown.add(e.code); });
  document.addEventListener("keyup", function (e) { keysDown.delete(e.code); });
  // If the tab loses focus mid-press we never see the keyup — release all.
  window.addEventListener("blur", function () { keysDown.clear(); });

  createjs.Ticker.setFPS(60);
  createjs.Ticker.addEventListener("tick", function (event) {
    car.update();
    stage.update(event);
  });
}