wololo中文

How we manage pixel assets without image files

Meme Brawl and Runekeep ship without a single image file. How their sprites are written as text, compiled to canvases, outlined, recoloured and scaled.

Open the folders for Meme Brawl and Runekeep and you will not find a single PNG. Every hero, monster, weapon and backdrop lives in JavaScript: a sprite is an array of strings, and a background is a painting routine that runs once. This post walks through how both games define, cache, animate and scale that art, and how to add a sprite.

A sprite is an array of strings

In both games, each string is a row of pixels, each character a pixel, and any character missing from the palette (by convention .) is transparent. Here is the 14×9 slime from Runekeep and two entries from its monster table, both in draft/runekeep/js/art.js:

const SLIME_A = [
  '....gggggg....',
  '..ggGGgggggg..',
  '.gGGggggggggd.',
  '.gGgggggggggd.',
  'ggwxggggwxggdd',
  'ggxxggggxxggdd',
  'ggggggggggggdd',
  '.gggggggggggd.',
  '..dddddddddd..',
];
// ...
  slime: { rows: [SLIME_A, SLIME_B], air: false, spd: 0.9, fps: 3, pc: ['#6cc94f', '#b5ee7a', '#2f7a3a'] },
  slimeB: { rows: [SLIME_A, SLIME_B], swap: { g: '#5aa0f0', G: '#b4dcff', d: '#2c5aa8' }, air: false, spd: 0.95, fps: 3, pc: ['#5aa0f0', '#b4dcff', '#2c5aa8'] },

The games differ in where the palette lives. Runekeep has one global PAL: 28 letters, one colour each, shared by every sprite, with two shades of a hue often split by case (g grass green, G a paler green). Meme Brawl gives each sprite its own palette, so g is grey on the pigeon and light brown on the chonky bunny. One table keeps a game on one set of colours; per-sprite palettes free each meme face to pick its own, but every grid must be read next to its palette.

Meme Brawl also has mir() for symmetrical faces: draw the left half and each row is mirrored around its last character. Chad's head is 8 characters wide in the source and 15 pixels once compiled.

Compiled once into offscreen canvases

Both games turn the strings into canvases at load time; after that, a sprite is one drawImage call.

Meme Brawl's sprite(rows, pal) in core.js calls fillRect per pixel into an offscreen canvas sized to fit. HEADS, WSPR (weapons) and ISPR (items) are top-level constants in art.js, built as the script runs.

Runekeep's compile(rows, swap) writes straight into ImageData and adds a 1px ink outline around every filled pixel, so every figure shares one line weight and each canvas is two pixels larger than its grid. game.js calls buildSprites() (from draft/runekeep/js/art.js) on its first lines:

function buildSprites() {
  const S = {};
  for (const [name, k] of Object.entries(KINDS)) {
    const f = k.rows.map((r) => compile(r, k.swap));
    S[name] = { f, w: f.map((c) => silhouette(c, '#ffffff')) };
  }
  S.wiz = compile(WIZARD);
  S.wizCast = compile(WIZARD, { c: '#e6fbff', w: '#ffffff' });
  S.heart = compile(HEART);
  S.heartEmpty = compile(HEART, { r: '#2e2850', w: '#2e2850' });
  return S;
}

A swap overrides a few PAL entries: the blue and purple slimes, the casting wizard and the empty heart are existing grids with new colours. silhouette() fills a sprite with one colour via source-in compositing, so every animation frame gets a white copy up front and a monster that takes a hit swaps to it. The tower's red damage flash, TWR.red, works the same way.

Meme Brawl recolours in a loop. The SUS alien has five suit colours and two walking frames, so ten canvases are built at load (draft/meme-brawl/js/art.js):

const SUS_COLORS = {
  red: ['#d72c2c', '#9a1a24'],
  cyan: ['#3ad8e8', '#2290a8'],
  orange: ['#f0902a', '#b8601a'],
  purple: ['#8a4ad8', '#5a2a98'],
  black: ['#3a3a48', '#22222c'],
};
const SUS_SPR = {};
for (const [name, [r, R]] of Object.entries(SUS_COLORS)) {
  const pal = { r, R, v: '#93d5ef', V: '#5a96bd', w: '#ffffff', h: '#b4ec8c', k: '#14141a' };
  SUS_SPR[name] = SUS_LEGS.map((legs) => sprite([...SUS_BODY, ...legs], pal));
}

Note the reuse: one body, two sets of legs, and the Impostor boss draws the same sprites at a whole-number scale of 3. Runekeep's goblin, skeleton and ghost likewise share upper halves via GOB_TOP.concat([...]).

Making it move: swap frames or build the figure live

Runekeep cycles frames. drawEnemy() picks one with Math.floor((G.T + e.ph) * K.fps) % S.f.length, where G.T is elapsed seconds and e.ph a random phase per monster, so a crowd of slimes never bounces in lockstep. Each kind sets its own fps: 3 for slimes, 8 for bats. The 32×24 dragon is drawn at double size and the ghost semi-transparent. Monsters face left and the wizard faces right, so nothing is ever flipped.

Meme Brawl's small enemies use two frames too; the pigeon alternates with Math.floor(t / 5) & 1. Heroes and human enemies are paper dolls instead: the head is a sprite, and drawRig() paints the body every frame from line segments and rectangles. LOOKS holds each figure's colours, torso size, limb lengths, sleeves and accessories; poseOf() in entities.js holds the motion, each pose a handful of joint angles. Far-side limbs are darkened to 0.74 with shade() for depth. Thirteen heads and fourteen looks cover every hero, every human enemy and most bosses, with no hand-drawn animation sets.

Outlines and hit flashes: baked or per frame

Runekeep bakes outlines in at compile time, so they cost nothing at runtime. Meme Brawl's fighters are assembled every frame, flipped to face either way and rotated when knocked down, so there is no finished image to outline in advance. figEnd() in core.js redoes it every frame:

function figEnd(x, y, o = {}) {
  // ...
  tintX.globalCompositeOperation = 'source-in';
  tintX.fillStyle = o.outline || INK;
  tintX.fillRect(0, 0, s, s);
  // ...
  outX.drawImage(tintC, 0, 0, s, s, 0, 1, s, s);
  outX.drawImage(tintC, 0, 0, s, s, 2, 1, s, s);
  outX.drawImage(tintC, 0, 0, s, s, 1, 0, s, s);
  outX.drawImage(tintC, 0, 0, s, s, 1, 2, s, s);
  outX.drawImage(figC, 0, 0, s, s, 1, 1, s, s);
  // ...
  if (o.flip) d.scale(-1, 1);
  if (o.rot) d.rotate(o.rot);

The figure is drawn into a scratch canvas of up to 168×168. An ink silhouette is stamped one pixel left, right, up and down, the original goes back on top, and the result is flipped, rotated and placed in the world. The outline colour is a parameter: elite enemies get #7a3a00 and a gold shirt. A hit flash paints it white with source-atop.

That costs several extra draws per fighter per frame, but three shared scratch canvases serve every character. Items and weapon icons never change shape, so inked() outlines them once and caches the copy in a Map.

Scaling up without blur

Both games paint into a 384×216 buffer and scale it up to the screen. Three settings keep it sharp: contexts from ctx2d() have imageSmoothingEnabled off, the final upscale turns it off again, and the <canvas> has image-rendering: pixelated.

Runekeep's fit() adds one step: once a game pixel spans at least 3 device pixels, the scale is rounded down to a whole number so every pixel is the same size. Meme Brawl's fitView() uses whatever ratio the window allows. The chonky bunny's wind-up squash deliberately stretches its canvas by a non-integer factor.

Most text skips the pixel buffer. Meme Brawl draws Chinese text and captions in Anton, Comic Neue and Noto Sans TC on the full-resolution canvas after the upscale; only scenery text (signs, graffiti, tombstones) uses the 3×5 pixel font in core.js, each glyph five rows of three bits. Runekeep's letter and word labels above the monsters use DotGothic16, also drawn after the upscale, one character at a time because canvas text cannot turn ligatures off. Its menus are plain HTML.

Backgrounds are code too

Backgrounds are painting routines seeded through rng(seed), so they look the same on every run. Runekeep's makeBackdrop() uses a 4×4 Bayer matrix for ordered dithering on the sky gradient, the moon's halo and the grass, painted once into a 384×216 canvas. Of the scenery, only stars, clouds, torches and the flag are drawn per frame. The tower is split into back and front canvases so the wizard can stand between wall and parapet.

Meme Brawl's paintStage() paints a near layer when a level is created, plus a far layer for most themes, each with its own seed. stageBack() scrolls them at different speeds for parallax and redraws only moving parts such as neon signs and the stock ticker.

Trade-offs

The upside: there are no images to load, so sprites exist the moment the scripts run. Changing a pixel means changing a character, and git diff shows exactly what moved. A recolour is one swap object or one loop.

The downside: a character grid in a text editor has no preview, so you count cells. A row one character short raises no error; it is just a pixel narrower. And the two outline approaches trade memory for work: Runekeep stores a white silhouette per animation frame, while Meme Brawl redraws every outline on every render.

Adding a new sprite

Runekeep

  1. Draw the grid in art.js with PAL letters, facing left. Skip the outline; compile() adds it.
  2. For animation, draw two frames, using concat if the top half is shared.
  3. Add a KINDS entry with rows, air, spd, fps and death-particle colours pc, plus swap, alpha or scale if needed. buildSprites() compiles it and makes the white flash frames.
  4. Add the name to the air or ground list in spawn() in game.js, or it will never appear.

For a recolour, skip the first two steps and add a KINDS entry with a swap.

Meme Brawl

  • Human figures: add a right-facing head to HEADS with mkHead(rows, pal, ax, ay, eye), giving the neck anchor and the eye row (where shades and laser eyes go), and mir() for symmetry. Add a look to LOOKS and point an ENEMIES entry in data.js at it with look. poseOf() supplies every pose.
  • Non-human enemies: build the image with sprite(rows, pal) in art.js, write a draw function that places it with stamp(), add a case to Enemy.drawBody() in entities.js, and name it in ENEMIES with sprite. It must fit the default 64×64 scratch area (figSize); figEnd() handles outline, flash and flip.
  • Items and weapons: add them to ISPR and WSPR under the same keys as ITEMS and WEAPONS in data.js. Weapons use mkW() to mark the grip and should point right.

Design notes

  • Designing Inkloop: loops, cuts and seals

    Why the only way to die in Inkloop is to be enclosed, why cutting never kills, how the bots are tuned to feel human, and what changed after simulation.

  • Game sound with no audio files

    Inkloop, Meme Brawl and Runekeep ship no audio files. Every effect and song is synthesized live with the Web Audio API. Here is how, and what it costs.

  • How Runekeep turns typing practice into tower defence

    Inside Runekeep's code: how targets lock, how difficulty ramps, why Letter Trial drills one row at a time, and how WPM and accuracy are counted.