Game feel: floating text and hit-stop
Small things that make a blow land: a number that pops off the body, a freeze-frame, a recoil, a white flash, the screen shaking along the blow. The engine gives you the pooled text and the numbers; when to freeze is the game's.
Floating text
FloatingText draws damage numbers, pickups and "+1" from bitmap fonts and reuses them. A crowded fight throws dozens a second; as canvas text, each one repainted a canvas and uploaded it.
import { FloatingText, type FloatingTextStyle } from '1681-engine';
const HIT: FloatingTextStyle = { size: 10, color: '#ffffff' };
const CRIT: FloatingTextStyle = { size: 14, color: '#ffd040' };
await document.fonts.load('10px "My Pixel Font"'); // a font must be loaded before it is baked
const numbers = new FloatingText(this.engine.stage, { fontFamily: 'My Pixel Font', depth: 900_000, parent: this.root });
numbers.warm([HIT, CRIT]); // bake the fonts ahead, one a task, starting a second from now
// on a hit
numbers.float(target.x, target.y - 20, String(damage), crit ? CRIT : HIT, { rise: 16, driftX: (Math.random() - 0.5) * 8 });
// onExit
numbers.clear();- A style is a size and a colour, and each is its own baked font. Bake them while the world loads (
warm, orinstallfor one now): a style first met mid-fight baked its glyph pages on that frame, and ten at once froze a world's first frame half a second. floatpops a label frompop× (1.4) to full size, holds itholdMs, then risesrisepx while fading overfadeMs, and hands it back to the pool.takeandreleaseare the manual form, for a motion of your own.parent: this.rootmakes the labels live and die with the scene. On the default (the stage's world layer) they outlive it, and one still floating when the scene changes stays.- Bitmap fonts bake ASCII only. Letters beyond it (é, ñ, ư) go in
glyphs, or inaddLabelGlyphsfor every font. driftXwith a little randomness keeps a crowd of numbers from stacking on one spot.
examples/platformer/Effects.ts floats a "+1" over every coin.
Hit-stop, recoil and the white flash
hitStop holds the curves a game tuned on phones, pure so the numbers can be pinned in a test:
import { hitStopMs, hurtFlashMs, recoilAt, recoilPx, RECOIL_MS } from '1681-engine';
const share = damage / target.maxHp;
const freeze = hitStopMs(share, crit); // 40 ms for a scratch, up to 140 for a third of the body; a crit +40
const push = recoilPx(target.isBoss, share, crit); // whole world pixels away from the striker: 1–3, a big body barely
const flash = hurtFlashMs(target.isBoss, crit && mine, settings.reduceFlashes); // 70, 140 for one's own crit, 0 reducedApplying them is the game's. A freeze-frame is simplest as a pause of the simulation, not of the drawing:
class Level extends Scene {
private frozenUntil = 0;
hit(target: Enemy, damage: number, crit: boolean, angle: number) {
this.frozenUntil = performance.now() + hitStopMs(damage / target.maxHp, crit);
target.recoil = { px: recoilPx(target.isBoss, damage / target.maxHp, crit), angle, at: performance.now() };
this.engine.stage.cameras.main.shake(90, 0.004, angle); // along the blow
}
override fixedUpdate(dt: number) {
if (performance.now() < this.frozenUntil) return; // the world holds; the frame still draws
// … the simulation …
}
override update() {
for (const e of this.enemies) {
const t = (performance.now() - e.recoil.at) / RECOIL_MS;
const off = recoilAt(e.recoil.px, t); // out at once, eased back, whole pixels only
e.view.x = Math.round(e.x + Math.cos(e.recoil.angle) * off);
}
}
}The scene's world runs its fixed systems before fixedUpdate, so a simulation that lives in systems checks the same frozenUntil at the top of each of them. Keep the freeze short (the curve stops at 180 ms with a crit): longer reads as a stall, and in multiplayer only the client's own view should hold, never the server's step.
A heavy body (a boss, anything drawn large) flashes only for the local player's own crit, 60 ms, at most every HEAVY_FLASH_GAP_MS (400): a sheet covering most of the screen turning white on every blow is a strobe. Sparks, numbers and the recoil still mark every blow. Offer a "reduce flashes" setting and pass it to hurtFlashMs.
A flash without a filter: solidSilhouette
setTintFill fills a body through a filter, and a filter is a render pass of its own: an area blow landing on twenty bodies cost twenty passes for a few frames, which dragged a crowded fight down on weak GPUs. solidSilhouette makes the frame's flat silhouette once, as a texture, and caches it; a flashing body only swaps its texture and still batches with everything else.
import { solidSilhouette, clearSilhouettes } from '1681-engine';
const normal = body.texture;
const white = solidSilhouette(normal); // cached per frame; null when the frame cannot be read
if (white) {
body.texture = white;
body.tint = 0xff4040; // a white silhouette tinted is that colour wherever the body is opaque
} else body.setTintFill(0xff4040); // the filter as a fallback
this.delay(flash, () => {
body.texture = normal;
body.tint = 0xffffff;
});Ask for white and tint it: one texture per frame serves every colour (a color argument bakes one in instead). Only for frames that are never repainted (atlas frames, snapshots); a body repainted every frame is copied first (TextureRegistry.snapshotInto) and the copy painted with fillSolid(canvas, color). clearSilhouettes(texture) frees one frame's silhouettes, clearSilhouettes() all of them (an atlas rebuilt).
Afterimages
Afterimages leaves fading copies of a moving body — a dash, a blink — as tinted silhouettes, from a pool, at most one every spacingMs:
import { Afterimages } from '1681-engine';
const ghosts = new Afterimages(stage, { tints: [0xffffff, 0x9fe6ff, 0x5a9cff], alpha: 0.6, durationMs: 260, spacingMs: 22 });
// each frame of a dash; the copy stands where the body is drawn, just behind a body sorted by y
const copy = ghosts.trail(body, hero.x + body.x, hero.y + body.y, now, hero.y - 1);
if (copy) drawSpeedStreak(); // null: too soon, or the frame could not be readA body marked live (TextureRegistry.markLive, repainted every frame) is snapshotted into a ring of slots textures that are reused, never a new canvas a copy. Copies blend 'add' by default (light); pass blend: 'normal' for painted shadows. destroy() takes the copies and the ring's textures away.
Skipping what nobody sees
inView(camera, x, y, margin) says whether a world point is within margin world pixels of the screen. Effects use it to skip painting off screen; Emitter and paintLayer already do.
API
FloatingText · FloatOptions · hitStopMs · recoilPx · recoilAt · hurtFlashMs · inView · Camera.shake · solidSilhouette · fillSolid · clearSilhouettes · Afterimages