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
- Kinds
- Headings
- Telegraphs
- Straight lasers
- Curvy lasers
- Cancels
- Events
- Interpolation
- Hit circles
- Layering and blending
What you get
After every tick there are three things to read:
- Bullets: every live actor except lasers, as parallel arrays in spawn order. Despite the name, that includes the enemy the host spawned and any invisible emitters.
- Lasers: one entry per laser, and one buffer of points that all of them share.
- Events: what happened during the tick, oldest first.
| 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:
- Don't hit-test the drawn positions. Test on the arrays as the tick left them, so hits come out the same at any frame rate.
- Don't extrapolate from
headingandspeedto save the tick of latency. It's wrong for curves,accelandmove-to, which don't show in those two. Interpolation needs only positions.
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:
- a circle on each bullet with
CanHit, at its hit radius, where the bullet is drawn. A dark fill with a light edge shows up over any colour. - the band each live laser hits within: a stroke
widthwide with round caps and joins along its polyline, which is that area exactly. - the player's own circle.
The testbed's hitbox button and the example both show these on H.
Layering and blending
Draw back to front. This order works:
- the background
- enemies
- the player's ship
- items
- lasers
- bullets, telegraphs with them
- effects: flashes and bursts
- debug hit circles
- 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.