Drawing bs

The VM doesn't draw anything. After each tick it publishes every live actor as flat arrays, and queues events for what happened. The host turns those into sprites, beams and effects. This guide covers how.

embedding.md covers the calls that get you the arrays, and hosts/javascript.md the JavaScript versions used in the snippets here. examples/web-renderer does everything below with canvas shapes, in one file you can run.

What you get

After every tick there are three things to read:

Bullet array What it holds
id the actor's id; a new actor never gets the id of one that's still alive
x, y position in field units, y up
heading degrees in [0, 360), counterclockwise from +x
speed units per tick
age ticks since it appeared
kind what to draw, as a symbol id
flags Host 1, Scripted 2, CanHit 4, Cancellable 8
delay ticks left before it can hit, 0 once it can
tags its tags, one bit each
Laser array What it holds
id, kind, flags, tags as for bullets
width the beam's width in field units
phase 0 warn, 1 active, 2 fade: LaserPhase in JS, BS_LASER_WARN and so on in C
progress how far through the phase, from 0 up to 1
pointsStart, pointsLen where its polyline sits in points, counted in points
points x, y pairs for every laser, each one tail to head
const b = vm.bullets();
for (let i = 0; i < b.count; i++) {
  const spec = look(b.kind[i]);
  if (spec.draw) spec.draw(b.x[i], b.y[i], b.heading[i]);
}

The arrays are views into the VM's memory, and they only last until the next call into the VM. Read them again after each tick, and copy what you want to keep, such as last tick's positions for interpolation.

The field is in bs's own units with y up, and the screen's y runs down. Flip y when you draw:

const sx = x * SCALE, sy = (H - y) * SCALE;

Set cull bounds a little outside the field, at least half your biggest sprite out, so bullets aren't removed while part of them still shows. The VM removes anything outside the bounds at the end of each tick. With no bounds set, nothing is culled, and shots that leave the field fly on for good.

Kinds

A kind is a symbol, like 'small-red or 'needle-cyan, and it's all that says what to draw. The VM doesn't know what a kind looks like: the host keeps a table from names to sprites.

The kind array holds symbol ids. Turn an id into a name with vm.kindName(id) once, and cache what you look up by id. Ids belong to one VM, so start a fresh cache when you make a new one.

const byKind = [];
const look = (id) => (byKind[id] ??= lookNamed(vm.kindName(id)));

Sprite sheets. Map each name to frames on a sheet. Animate from age rather than a frame counter of your own, so animations stay in step with their bullets through pauses, rewinds and replays:

const frame = spec.frames[Math.floor((b.age[i] * spec.fps) / 60) % spec.frames.length];
ctx.drawImage(sheet, frame.x, frame.y, spec.w, spec.h, sx - spec.w / 2, sy - spec.h / 2, spec.w, spec.h);

Unknown kinds. A script can use any symbol, so a typo is still a kind. Draw names you don't know as your default bullet rather than as nothing, so the shot can still be seen and dodged, and warn once per name. The testbed does this.

Colour variants. Name kinds shape-colour and split the name, so one shape comes in every colour:

const [shape, colour] = name.split('-');
const spec = { ...(SHAPES[shape] ?? SHAPES.bullet), color: COLOURS[colour] };

With sprites, either keep a frame per colour on the sheet, or draw one white sprite tinted per colour. Canvas 2D can't tint as it draws, so tint copies into offscreen canvases once at load. raylib and LÖVE tint as they draw: pass the colour as DrawTexturePro's tint, or call love.graphics.setColor first.

Kinds that draw nothing. 'emitter is the kind spawn gives an actor unless told otherwise, and the usual kind for an invisible actor a script fires to shoot from somewhere else. Don't draw it. A fired emitter still gets CanHit once its delay is over: the flag says the actor is allowed to hit, not that there's anything to see. Give 'emitter no hit radius, unless your game wants invisible bullets that hurt, as the Invisible Full Moon recreation does.

Enemies. The actor the host spawned has the Host flag. Enemies a script fires, like the drones in 16-boss.bs, don't, so tell them apart by kind.

Lasers have kinds too, 'laser unless the script says otherwise. They come in their own arrays and draw as beams, so give them their own table: a colour, or a beam texture.

Headings

Round bullets look the same from every side, so draw them as they are. Needles, arrows, rice and knives should point where they're going, so rotate them to heading.

Headings are counterclockwise with y up. With y flipped for the screen, turn by minus the heading:

ctx.save();
ctx.translate(x * SCALE, (H - y) * SCALE);
if (spec.turns) ctx.rotate((-b.heading[i] * Math.PI) / 180);
ctx.drawImage(sheet, frame.x, frame.y, spec.w, spec.h, -spec.w / 2, -spec.h / 2, spec.w, spec.h);
ctx.restore();

Draw directional sprites pointing right on the sheet, or note the angle each one points at and subtract it. In raylib, DrawTexturePro takes degrees, so pass -heading. In LÖVE, love.graphics.draw takes radians, so pass -math.rad(heading).

A shot on a 'polar or 'wave path faces along the path. One pushed by accel doesn't: its heading is where it's aimed, and the drift comes on top. To point it the way it actually moves, use its change in position since last tick.

Telegraphs

fire :delay n makes a shot that can't hit for its first n ticks. Until then its CanHit flag is clear, and delay counts the ticks left. Draw these so the player reads them as warnings: faded, as an outline, or inside a ring that closes in as the delay runs out.

if (b.delay[i] > 0) {
  ctx.strokeStyle = spec.color;
  ctx.globalAlpha = 0.6;
  ctx.stroke(spec.path);
  ctx.globalAlpha = 0.3;
  ctx.beginPath();
  ctx.arc(0, 0, spec.r + Math.min(b.delay[i], 30) * 0.4, 0, Math.PI * 2);
  ctx.stroke();
  ctx.globalAlpha = 1;
}

The VM reports only the delay left, not what it started at, so cap it as above, or remember the first value you see for each id. In raylib, DrawCircleLinesV draws the ring and Fade(color, 0.4f) the faded sprite. In LÖVE, love.graphics.circle("line", x, y, r) and a colour with alpha.

Use CanHit to decide what hits, and delay only to draw. They agree for bullets, but the enemy the host spawned has a delay of 0 and never has CanHit.

Straight lasers

Lasers aren't in the bullet arrays. Each one has a polyline in points, and a straight laser's is two points: where it's fired from, then its tip. It goes through three phases, and progress says how far through the current one it is.

Phase Hits Draw it as
warn (0) no a thin line, flickering faster as progress nears 1
active (1) yes, once any :delay is over the full beam, width wide
fade (2) no the beam thinning out: width * (1 - progress) wide, and fading too

A laser fired with a :delay longer than its warning reaches the active phase without CanHit, and only gets it when the delay runs out. Draw it as a warning until the flag appears.

const L = vm.lasers();
for (let i = 0; i < L.count; i++) {
  const p = 2 * L.pointsStart[i], pts = L.points;
  ctx.beginPath();
  ctx.moveTo(pts[p] * SCALE, (H - pts[p + 1]) * SCALE);
  ctx.lineTo(pts[p + 2] * SCALE, (H - pts[p + 3]) * SCALE);
  ctx.strokeStyle = laserColour(L.kind[i]);
  ctx.lineCap = 'round';

  const t = L.progress[i];
  const warning =
    L.phase[i] === LaserPhase.Warn || (L.phase[i] === LaserPhase.Active && !(L.flags[i] & Flag.CanHit));
  if (warning) {
    ctx.lineWidth = 1;
    ctx.globalAlpha = Math.floor(tick / (t > 0.7 ? 2 : 4)) % 2 ? 0.25 : 0.6;
  } else {
    const left = L.phase[i] === LaserPhase.Fade ? 1 - t : 1;
    ctx.lineWidth = L.width[i] * left * SCALE;
    ctx.globalAlpha = left;
  }

  ctx.stroke();
  ctx.globalAlpha = 1;
}

A beam looks better with a soft edge: stroke the same line two or three times, wide and faint in the laser's colour, then narrower, then a thin white core.

Curvy lasers

A curvy laser's polyline has :segments + 1 points, from the tail, the oldest part of the trail, to the head. The count never changes over the laser's life. Draw it as one thick line with joins and end caps.

In canvas 2D, a stroke does all of that:

ctx.beginPath();
for (let k = 0; k < L.pointsLen[i]; k++) {
  const p = 2 * (L.pointsStart[i] + k);
  ctx.lineTo(L.points[p] * SCALE, (H - L.points[p + 1]) * SCALE);
}
ctx.lineWidth = L.width[i] * SCALE;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.stroke();

lineTo on an empty path starts it, so there's no need for a moveTo. Round ends also match the area the laser hits (see Hit circles).

On a GPU, or to texture the beam, build a triangle strip: two vertices for each point, half the width out to either side. Average the normals of the segments on either side of a point so the edges meet, and scale the offset up at bends so the beam keeps its width there, up to a limit so sharp bends don't spike. Skip segments of zero length. They're common: a curvy laser starts with all its points on top of each other at its origin, and early in its life several still share a place.

// Two vertices for each point of `pts`, x, y pairs in screen space, for a triangle strip.
function strip(pts, width) {
  const out = [], n = pts.length / 2, half = width / 2;
  for (let k = 0; k < n; k++) {
    const x = pts[2 * k], y = pts[2 * k + 1];
    let nx = 0, ny = 0, first = null;
    for (const [a, b] of [[k - 1, k], [k, k + 1]]) {
      if (a < 0 || b >= n) continue;
      const dx = pts[2 * b] - pts[2 * a], dy = pts[2 * b + 1] - pts[2 * a + 1];
      const d = Math.hypot(dx, dy);
      if (d < 1e-6) continue;
      first ??= [-dy / d, dx / d];
      nx -= dy / d;
      ny += dx / d;
    }

    const d = Math.hypot(nx, ny) || 1;
    nx /= d;
    ny /= d;
    const reach = half / Math.max(first ? nx * first[0] + ny * first[1] : 1, 0.4);
    out.push(x + nx * reach, y + ny * reach, x - nx * reach, y - ny * reach);
  }
  return out;
}

To tile a texture along the beam, give each vertex pair a texture coordinate from the distance along the polyline so far. The testbed's renderer.ts does this in WebGL.

In raylib 5, DrawSplineLinear(points, count, width, color) draws a thick polyline, and a DrawCircleV of radius width / 2 at each point rounds its joins and ends. In LÖVE, set love.graphics.setLineWidth(width) and love.graphics.setLineJoin("miter"), then call love.graphics.line(points). Its lines end flat, so draw a circle at each end for round ones.

Cancels

cancel, from a script or from the host, removes cancellable actors, and queues a cancelled event for each in place of the usual despawned one. The event has the actor's kind and where it was, so you can leave something behind. Most shmups turn cancelled bullets into score items.

for (const e of vm.events()) {
  if (e.type === 'cancelled' && lookNamed(e.kind).draw) {
    items.push({ x: e.x, y: e.y, born: tick });
  }
}

A cancel takes cancellable lasers and emitters too, and their events look the same, so check the kind before you leave an item. Items live only in the host: the VM doesn't know about them, so they can't change the simulation or its replays. Animate them on the tick count, say rising and fading over 50 ticks.

Events

vm.events() returns everything queued since the last call, and clears the queue.

Event Fields What to do with it
emit id, name, x, y, and value if the script gave one effects, sounds, screen shake, phase changes
despawned id, reason, x, y a burst where a bullet vanished
cancelled id, kind, x, y an item
error id, message, line, col log it; that actor has stopped
spawned id, kind off unless you call setSpawnEvents(true)
dropped count the queue filled up and the oldest events went

Emits carry the emitting actor's position. Keep a table from names to effects and ignore names you don't know. In the example, (emit 'flash 60) opens a ring out to 60 units round the enemy: the value sets the size.

Despawns say why the actor went:

Reason When Draw
vanish its script called (vanish), or it was attached with :attach 'vanish and its parent went a small burst
cull it left the bounds nothing: it's off screen
kill the host called kill whatever your game does there
finished a laser's fade ended, or the script of an actor the host spawned ended nothing for lasers; an explosion for an enemy
error its script failed, right after an error event nothing

A despawned event doesn't carry the kind. To pick an effect by kind, find the id in last tick's arrays, which you keep anyway if you interpolate.

Hit flash. Hits are the host's job (see Hit circles), and so is showing them. When the player is hit, play a burst where it is, and blink the ship for a fixed number of ticks while it can't be hit again:

if (flash > 0) flash--;
else if (hit) {
  flash = 40;
  bursts.push({ x: player.x, y: player.y, born: tick });
}

if (flash === 0 || Math.floor(flash / 3) % 2) drawShip();

Interpolation

bs counts time in ticks, 60 a second, and the host calls tick for each one. Tick from an accumulator, so the game runs at the same speed whatever the frame rate. Cap what's owed, so a stall doesn't run hundreds of ticks at once:

behind = Math.min(behind + now - last, 250);
last = now;
while (behind >= STEP) {
  tick();
  behind -= STEP;
}
draw(behind / STEP);

When the display doesn't run at 60 Hz, on a 144 Hz monitor, after a dropped frame or in slow motion, some frames fall between ticks and moving things judder. The fix is to draw each actor part way from where it was last tick to where it is now, a = behind / STEP of the way. It costs a tick of latency.

Copy last tick's arrays before you call tick, since the views get overwritten. Then pair each actor with its last position by id. The arrays are in spawn order, survivors keep their order, and new actors come last, so one pass pairs them:

// Each actor's index last tick, or -1 if it's new.
function pair(ids, before) {
  const out = new Int32Array(ids.length);
  let j = 0;
  for (let i = 0; i < ids.length; i++) {
    while (j < before.length && before[j] !== ids[i]) j++;
    out[i] = j < before.length ? j++ : -1;
  }
  return out;
}

const lerp = (from, to, t) => from + (to - from) * t;
const lerpAngle = (from, to, t) => from + (((to - from + 540) % 360) - 180) * t;

const from = pair(b.id, prev.id);
for (let i = 0; i < b.count; i++) {
  const j = from[i];
  const x = j < 0 ? b.x[i] : lerp(prev.x[j], b.x[i], a);
  const y = j < 0 ? b.y[i] : lerp(prev.y[j], b.y[i], a);
  const heading = j < 0 ? b.heading[i] : lerpAngle(prev.heading[j], b.heading[i], a);
}

Headings go the short way round, or a needle crossing 0° spins a full turn. Lasers pair by id the same way, and since a laser's point count never changes, interpolate it point by point. Run effects and items on the same clock: what's on screen is tick tick - 1 + a.

Two things not to do:

An actor the host moves with setPosition jumps, and interpolation smears the jump over a tick. If that shows, skip interpolating anything that moved further than it could in one tick.

In LÖVE, accumulate in love.update(dt) and use behind / STEP in love.draw. In raylib, accumulate GetFrameTime() in the main loop.

Hit circles

Shmups make the hit area much smaller than the sprite: a bullet hits with its bright core, and the player with a dot at the ship's centre. Keep a hit radius for each kind beside its sprite, and test only what has CanHit:

for (let i = 0; i < b.count; i++) {
  const r = look(b.kind[i]).hit;
  if (!r || !(b.flags[i] & Flag.CanHit)) continue;
  if (Math.hypot(b.x[i] - player.x, b.y[i] - player.y) < r + PLAYER_HIT) hit = true;
}

A laser hits when the player comes within half its width of its polyline, plus the player's radius. bs dodge measures it the same way. The testbed's hits.ts has a distance-to-polyline function you can copy.

The testbed's kinds.ts works its radii out from the sprites, which makes a reasonable start:

Hit radius
round bullet 60% of its drawn radius
long bullet, like a needle 70% of half its thickness
enemy 45% of half its smaller side
the player 2.5 units

For debugging, draw what can hit on a key:

The testbed's hitbox button and the example both show these on H.

Layering and blending

Draw back to front. This order works:

  1. the background
  2. enemies
  3. the player's ship
  4. items
  5. lasers
  6. bullets, telegraphs with them
  7. effects: flashes and bursts
  8. debug hit circles
  9. the player's hit dot

Bullets go over the ship and over lasers, so a shot is never hidden behind either. The hit dot goes over everything, since it's what the player dodges with. Within the bullets, the arrays' spawn order puts newer shots over older ones.

Additive blending suits lasers, flashes and glows: they get brighter where they overlap, as light does. Keep the solid bodies of bullets in normal blending, or a dense pattern washes out to white and can't be read.

Additive Back to normal
canvas 2D ctx.globalCompositeOperation = 'lighter' 'source-over'
raylib BeginBlendMode(BLEND_ADDITIVE) EndBlendMode()
LÖVE love.graphics.setBlendMode("add") love.graphics.setBlendMode("alpha")

Canvas 2D copes with a busy pattern but not a dense one. The testbed's renderer.ts draws with WebGL2, from one atlas in one instanced call a frame, because canvas 2D topped out around 3,000 sprites in its testing.