BroMetal for js13k
The core of BroMetal was built for exactly this: a renderer small enough to leave the budget for your game, with the expensive part — turning shaders into something a GPU can run — moved off the wire entirely.
| Runtime, minified and gzipped | 2,125 bytes |
| Runtime + a shader + a working game, zipped | 3,008 bytes |
| Left for your game | 10,304 bytes |
How is it so small?
The compiler never ships. You write shaders as typed TypeScript; a build step compiles them to WGSL on your machine and your game receives finished shader text. Nothing parses, type-checks or generates code in the browser, so none of that costs you bytes — and there is no compilation pause on the first frame.
A mistake is a build error, not a black screen. Shader bugs normally surface as a blank canvas with an empty console. Here a misspelled uniform, a wrong vector width or a reserved word fails on your machine with a file and line number, while you still have budget left to care.
Nothing is spent on being defensive. No validation, no error messages, no pipeline caching, no uniform ring buffers. Those are the right calls for a general-purpose library and the wrong ones at thirteen kilobytes, so this build simply does not have them.
What it does have is what a real entry needs: multiple shader programs, 2D textures from a canvas, instancing, alpha blending with depth writes off, depth testing, back-face culling, a matrix stack and mat4 helpers.
What it does not have is everything else. No audio, no input handling, no collision or physics, no text and no font rendering, no model loading, no scene graph, no asset pipeline. BroMetal draws triangles and does the matrix maths; a game is what you write in the remaining ten kilobytes. That is the usual shape of a js13k entry — worth knowing before you plan around a library that will hand you more.
Every file below is taken from a real, working project rather than written for this page. Browse the starter on GitHub to see them in place, with the build script and a README.
The runtime
This is all of it — plain source, global functions, no imports, so your minifier renames it alongside your own code and drops every function you never call.
You never copy this. The compiler writes it, next to your compiled shaders, every time you build. That is deliberate: a shader compiles down to a bare array — [wgsl, attrs, instanceAttrs, uniformBytes, textures] — that the runtime reads by position, with no names to check against. A runtime fetched from somewhere else could be a version out of step and would not complain; it would build a pipeline from the wrong slots and draw nothing. Emitting the pair from one command means they cannot disagree. Both carry the version that wrote them.
It is reproduced here so you can read what you are about to ship.
// BroMetal 0.17.2 — WebGPU runtime for 13 kB games. https://brometal.dev/js13k
//
// Written by `brometal prod --js13k` alongside dist/shaders.js, so the two are
// always from the same compiler. Everything is a global function: concatenate
// this with your shaders and your game, then minify the whole program at once.
//
// cat dist/brometal.js dist/shaders.js game.js > out.js
// terser out.js --compress --mangle --toplevel -o game.min.js
//
// --toplevel is the flag that pays: it renames these functions and deletes
// every one you never call. Comments cost nothing — they never reach the zip.
//
// Generated. Edit your shaders, not this file.
// Facts both runtimes must agree on.
//
// A separate module with *no state* on purpose. These first lived alongside the
// core's device and canvas variables, and importing them from `full` dragged
// that whole module in — mutable module-level bindings defeat tree-shaking, and
// the regular runtime grew 575 bytes gzipped to share four constants.
//
// Nothing here holds state, so a bundler drops whatever a consumer does not use.
// `--js13k` concatenates this ahead of the core, so the tiny build pays only for
// what it actually references.
// These are the WebGPU and compiler details that have no room for two answers.
// `full` imports them rather than restating them, because every one of them has
// a wrong spelling that fails silently rather than throwing.
/** COPY_DST 8 | VERTEX 32. Spelled numerically: `GPUBufferUsage.VERTEX` is a
* property access no minifier can shorten, and this file ships to a 13 kB budget. */
const BUF_VERTEX = 40;
/** COPY_DST 8 | INDEX 16. */
const BUF_INDEX = 24;
/** COPY_DST 8 | UNIFORM 64. */
const BUF_UNIFORM = 72;
/** COPY_DST 2 | TEXTURE_BINDING 4 | RENDER_ATTACHMENT 16. */
const TEX_UPLOAD = 22;
/** The entry points the compiler emits. Renaming one breaks both runtimes. */
const VS_ENTRY = 'vs_main';
const FS_ENTRY = 'fs_main';
/**
* Component count to vertex format. One component is `float32`, not
* `float32x1` — the latter is not a WebGPU format and rejects the whole
* pipeline, so a scalar instance attribute silently draws nothing.
*/
function vertexFormat(n) {
return (n > 1 ? `float32x${n}` : 'float32');
}
/**
* writeBuffer needs a 4-byte multiple from the source as well as the
* destination, and a Uint16 index list usually is not one — three indices for a
* triangle is six bytes. Returns the input untouched when it already aligns.
*/
function padTo4(data) {
if ((data.byteLength & 3) === 0)
return data;
const padded = new Uint8Array((data.byteLength + 3) & ~3);
padded.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
return padded;
}
//# sourceMappingURL=gpu.js.map
// BroMetal tiny — the core runtime.
//
// This is the single source of truth for the parts of WebGPU that are the same
// whatever you are building: how a buffer is created and filled, how an
// attribute format is spelled, which bindings the compiler assigns, how the
// depth texture tracks the canvas. Every runtime bug found so far lived here —
// buffer padding, usage bits, `float32x1` — which is the argument for one copy.
//
// It is also a complete renderer on its own. `brometal prod --js13k` emits it as
// plain source with the `export` keywords stripped, so a 13 kB game gets globals
// it can concatenate and mangle. `full` imports the same file and builds its
// heavier program and draw path on these primitives.
//
// Two rules keep it honest:
// - No validation and no messages. Guards are bytes a game could have spent.
// - No feature that only `full` needs. Every seam here costs the 13 kB build.
// Device-wide state. One device, one canvas, one depth buffer.
let bmDevice;
let bmCtx;
let bmFormat;
let bmDepth = null;
let bmCanvas;
let bmClear;
/** The render pass currently open inside bmLoop's callback. */
let bmPass;
// Model-view matrix stack, the shape SafeSpace used: mutate the current matrix,
// push before a subtree, pop after.
let bmM = bmIdentity();
const bmStack = [];
async function bmInit(canvas, clear) {
bmCanvas = canvas;
bmClear = clear || [0, 0, 0, 1];
const adapter = (await navigator.gpu.requestAdapter());
bmDevice = await adapter.requestDevice();
bmCtx = canvas.getContext('webgpu');
bmFormat = navigator.gpu.getPreferredCanvasFormat();
bmCtx.configure({ device: bmDevice, format: bmFormat, alphaMode: 'opaque' });
}
function bmProgram(wgsl, opts) {
const module = bmDevice.createShaderModule({ code: wgsl });
const attrs = opts.a || [];
const insts = opts.i || [];
const texes = opts.t || [];
// Binding 0 is always the uniform block; textures follow at the indices the
// compiler chose, so this layout has to mirror the emitted WGSL exactly.
const layoutEntries = [{ binding: 0, visibility: 3, buffer: {} }];
for (const [tex, samp] of texes) {
layoutEntries.push({ binding: tex, visibility: 2, texture: {} });
layoutEntries.push({ binding: samp, visibility: 2, sampler: {} });
}
const bindLayout = bmDevice.createBindGroupLayout({ entries: layoutEntries });
// One vertex buffer per attribute: simpler than interleaving, and the extra
// bind cost is irrelevant next to the bytes a packing scheme would take.
const buffers = attrs.map((n, i) => ({
arrayStride: n * 4,
attributes: [{ shaderLocation: i, offset: 0, format: vertexFormat(n) }],
}));
insts.forEach((n, i) => {
buffers.push({
arrayStride: n * 4,
stepMode: 'instance',
attributes: [
{ shaderLocation: attrs.length + i, offset: 0, format: vertexFormat(n) },
],
});
});
const pipeline = bmDevice.createRenderPipeline({
layout: bmDevice.createPipelineLayout({ bindGroupLayouts: [bindLayout] }),
vertex: { module, entryPoint: VS_ENTRY, buffers },
fragment: {
module,
entryPoint: FS_ENTRY,
targets: [{
format: bmFormat,
// A ternary, not `&&`: the falsy branch has to be undefined, and 0 is
// not a blend state.
blend: opts.blend
? {
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' },
alpha: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' },
}
: undefined,
}],
},
primitive: { topology: 'triangle-list', cullMode: opts.cull ? 'back' : 'none' },
// Transparent geometry tests against depth but must not write it, or the
// layers behind it get clipped away.
depthStencil: {
format: 'depth24plus',
depthWriteEnabled: opts.zwrite !== 0,
depthCompare: 'less',
},
});
const uniforms = bmDevice.createBuffer({ size: opts.u || 16, usage: BUF_UNIFORM });
return {
p: pipeline,
l: bindLayout,
ub: uniforms,
t: texes,
b: [],
ix: null,
n: 0,
bg: null,
tx: [],
};
}
// A vertex, instance or index buffer. `index` picks the INDEX usage bit.
// The buffer is pinned to ArrayBuffer rather than ArrayBufferLike: WebGPU will
// not accept a SharedArrayBuffer view, and the wider type makes that a runtime
// surprise instead of a compile error.
function bmBuffer(data, index) {
const size = (data.byteLength + 3) & ~3;
// Usage bits, spelled as numbers because GPUBufferUsage.* is far longer:
// COPY_DST 8 | INDEX 16 = 24 COPY_DST 8 | VERTEX 32 = 40
// Getting these wrong fails silently — WebGPU reports it as an uncaptured
// error, not a throw, so the canvas just stays black.
const buffer = bmDevice.createBuffer({ size, usage: index ? 24 : 40 });
// writeBuffer wants a 4-byte multiple from the *source* as well as the
// destination, and a Uint16 index list usually is not one — three indices for
// a triangle is six bytes. Pad rather than make every caller think about it.
if (data.byteLength & 3) {
const padded = new Uint8Array(size);
padded.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
data = padded;
}
bmDevice.queue.writeBuffer(buffer, 0, data);
return buffer;
}
function bmAttr(prog, slot, data) {
prog.b[slot] = bmBuffer(data);
}
function bmIndex(prog, data) {
prog.ix = bmBuffer(data, 1);
prog.n = data.length;
}
// A 2D texture from anything drawImage-able: an ImageBitmap, a <canvas>, an
// <img>. Procedural textures painted into a 2D canvas are the js13k staple, and
// they arrive here directly.
function bmTexture(source, smooth) {
const texture = bmDevice.createTexture({
size: [source.width, source.height],
format: 'rgba8unorm',
usage: TEX_UPLOAD,
});
bmDevice.queue.copyExternalImageToTexture({ source }, { texture }, [source.width, source.height]);
const filter = smooth === 0 ? 'nearest' : 'linear';
return {
v: texture.createView(),
s: bmDevice.createSampler({
magFilter: filter,
minFilter: filter,
addressModeU: 'repeat',
addressModeV: 'repeat',
}),
};
}
// Bind textures in the order the shader declares them. Rebuilding the bind
// group here rather than caching is deliberate: SafeSpace-style rendering swaps
// texture per batch, and a cache keyed on the set would cost more than it saves.
function bmTextures(prog, ...textures) {
prog.tx = textures;
prog.bg = null;
}
function bmUniforms(prog, floats) {
bmDevice.queue.writeBuffer(prog.ub, 0, floats);
}
// Draw the bound geometry. `count` instances, defaulting to one.
function bmDraw(prog, count) {
if (!prog.bg) {
const entries = [{ binding: 0, resource: { buffer: prog.ub } }];
prog.t.forEach(([tex, samp], i) => {
entries.push({ binding: tex, resource: prog.tx[i].v });
entries.push({ binding: samp, resource: prog.tx[i].s });
});
prog.bg = bmDevice.createBindGroup({ layout: prog.l, entries });
}
bmPass.setPipeline(prog.p);
bmPass.setBindGroup(0, prog.bg);
for (let i = 0; i < prog.b.length; i++)
bmPass.setVertexBuffer(i, prog.b[i]);
bmPass.setIndexBuffer(prog.ix, 'uint16');
bmPass.drawIndexed(prog.n, count || 1);
}
// The frame loop. Sizes the drawing buffer to the CSS box, rebuilds the depth
// texture when that changes, opens one render pass, and hands it to you.
function bmLoop(callback) {
const frame = (now) => {
const w = (bmCanvas.clientWidth * devicePixelRatio) | 0;
const h = (bmCanvas.clientHeight * devicePixelRatio) | 0;
if (bmCanvas.width != w || bmCanvas.height != h) {
bmCanvas.width = w;
bmCanvas.height = h;
if (bmDepth)
bmDepth.destroy();
bmDepth = bmDevice.createTexture({
size: [w, h],
format: 'depth24plus',
usage: 16,
});
}
const encoder = bmDevice.createCommandEncoder();
bmPass = encoder.beginRenderPass({
colorAttachments: [{
view: bmCtx.getCurrentTexture().createView(),
clearValue: bmClear,
loadOp: 'clear',
storeOp: 'store',
}],
depthStencilAttachment: {
view: bmDepth.createView(),
depthClearValue: 1,
depthLoadOp: 'clear',
depthStoreOp: 'store',
},
});
callback(now / 1000);
bmPass.end();
bmDevice.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
// ── Matrices ──────────────────────────────────────────────────────────────
// Column-major, the order WGSL expects, so a Float32Array of these goes
// straight into the uniform block.
function bmIdentity() {
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
function bmMul(a, b) {
const out = [];
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
let sum = 0;
for (let k = 0; k < 4; k++)
sum += a[k * 4 + j] * b[i * 4 + k];
out[i * 4 + j] = sum;
}
}
return out;
}
function bmPersp(fov, aspect, near, far) {
const f = 1 / Math.tan(fov / 2);
const d = 1 / (near - far);
return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * d, -1, 0, 0, 2 * far * near * d, 0];
}
function bmLook(eye, at, up) {
let z = [eye[0] - at[0], eye[1] - at[1], eye[2] - at[2]];
let l = Math.hypot(z[0], z[1], z[2]);
z = z.map((v) => v / l);
let x = [
up[1] * z[2] - up[2] * z[1],
up[2] * z[0] - up[0] * z[2],
up[0] * z[1] - up[1] * z[0],
];
l = Math.hypot(x[0], x[1], x[2]) || 1;
x = x.map((v) => v / l);
const y = [
z[1] * x[2] - z[2] * x[1],
z[2] * x[0] - z[0] * x[2],
z[0] * x[1] - z[1] * x[0],
];
return [
x[0], y[0], z[0], 0,
x[1], y[1], z[1], 0,
x[2], y[2], z[2], 0,
-(x[0] * eye[0] + x[1] * eye[1] + x[2] * eye[2]),
-(y[0] * eye[0] + y[1] * eye[1] + y[2] * eye[2]),
-(z[0] * eye[0] + z[1] * eye[1] + z[2] * eye[2]),
1,
];
}
function bmTrans(x, y, z) {
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
}
function bmScale(x, y, z) {
return [x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1];
}
function bmRotX(a) {
const s = Math.sin(a), c = Math.cos(a);
return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1];
}
function bmRotY(a) {
const s = Math.sin(a), c = Math.cos(a);
return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1];
}
function bmRotZ(a) {
const s = Math.sin(a), c = Math.cos(a);
return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
// Matrix stack, as SafeSpace used it: save before drawing a subtree, restore
// after, and let the current matrix be mutated in between.
function bmSave() {
bmStack.push(bmM.slice());
}
function bmRestore() {
bmM = bmStack.pop();
}
//# sourceMappingURL=index.js.map1. Set up the project
Start with this package.json. Both are dev dependencies — the compiler and the minifier run on your machine and neither ships. The build script is the one in step 4.
{
"name": "brometal-js13k",
"private": true,
"type": "module",
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs && npx serve dist"
},
"devDependencies": {
"brometal": "^0.17.0",
"terser": "^5.31.0"
}
}
Then install:
npm install
Write the package.json first rather than reaching for npm install --save-dev in an empty folder. With no package.json present, npm walks up the directory tree and installs into the first project it finds — which may be nowhere near your game, and leaves nothing recording what you depend on.
2. Set up shaders
Typed TypeScript, checked before it reaches a GPU. Save it as src/cube.shader.ts.
import { shader, vec4, texture, normalize, max, dot } from 'brometal';
/**
* One textured, lambert-lit shader. Written in BroMetal's typed TypeScript DSL
* and compiled to WGSL at build time — the compiler never ships, so none of this
* counts against the 13 kB.
*
* The exported name is the one the game uses — `Cube` here becomes the global
* `Cube` in dist/shaders.js, unchanged.
*/
export const Cube = shader({
attributes: { aPosition: 'vec3', aNormal: 'vec3', aUv: 'vec2' },
uniforms: { uMvp: 'mat4', uModel: 'mat4', uLight: 'vec3', uTex: 'sampler2D' },
varyings: { vNormal: 'vec3', vUv: 'vec2' },
vertex({ aPosition, aNormal, aUv }, { uMvp, uModel }, v) {
v.vNormal = uModel.mul(vec4(aNormal, 0)).xyz;
v.vUv = aUv;
return uMvp.mul(vec4(aPosition, 1));
},
fragment({ uLight, uTex }, { vNormal, vUv }) {
const lambert = max(dot(normalize(vNormal), normalize(uLight)), 0);
return vec4(texture(uTex, vUv).xyz.scale(lambert * 0.8 + 0.2), 1);
},
});
Then compile the shaders:
npx brometal prod --js13k
That writes both files you need into dist/: the runtime above, and shaders.js, where the shader appears under the name it exported itself as — export const Cube becomes const Cube, the global your game reaches for next. Nothing is derived from the file name, so there is no second name to look up; export default is a build error asking you to name it. Two shaders exporting the same name is an error too, since they share one scope.
3. Set up game
Everything is a global, so the whole program minifies as one unit. This one draws a spinning textured cube with the Cube shader from the previous step.
// Your game. Everything here is a global — no imports, no modules — so the
// minifier can mangle across this file, the runtime and the shaders together.
const cv = document.getElementById('c');
// A unit cube built at runtime rather than stored: 24 vertices is far more
// bytes as a literal than as the six-face loop that produces it.
const FACES = [
[1,0,0], [-1,0,0], [0,1,0], [0,-1,0], [0,0,1], [0,0,-1],
];
const pos = [], nrm = [], uvs = [], idx = [];
FACES.forEach((n, f) => {
// Two vectors spanning the face. Rotating the normal's components gives a
// perpendicular for any axis-aligned face; the cross of the two completes it.
const a = [n[1], n[2], n[0]];
const b = [n[1] * a[2] - n[2] * a[1], n[2] * a[0] - n[0] * a[2], n[0] * a[1] - n[1] * a[0]];
[[-1,-1],[1,-1],[1,1],[-1,1]].forEach(([s, t]) => {
pos.push(
(n[0] + a[0] * s + b[0] * t) * 0.5,
(n[1] + a[1] * s + b[1] * t) * 0.5,
(n[2] + a[2] * s + b[2] * t) * 0.5,
);
nrm.push(n[0], n[1], n[2]);
uvs.push((s + 1) / 2, (t + 1) / 2);
});
const v = f * 4;
idx.push(v, v + 1, v + 2, v, v + 2, v + 3);
});
bmInit(cv, [0.04, 0.04, 0.09, 1]).then(() => {
// Procedural texture painted into a 2D canvas — cheaper than any image.
const c2 = document.createElement('canvas');
c2.width = c2.height = 32;
const g = c2.getContext('2d');
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
g.fillStyle = (x ^ y) & 8 ? '#e9c46a' : '#2a9d8f';
g.fillRect(x, y, 1, 1);
}
}
const tex = bmTexture(c2, 0);
const p = bmProgram(Cube[0], {
a: Cube[1], i: Cube[2], u: Cube[3], t: Cube[4], cull: 1,
});
bmAttr(p, 0, new Float32Array(pos));
bmAttr(p, 1, new Float32Array(nrm));
bmAttr(p, 2, new Float32Array(uvs));
bmIndex(p, new Uint16Array(idx));
bmTextures(p, tex);
bmLoop((t) => {
const model = bmMul(bmRotY(t * 0.7), bmRotX(t * 0.4));
const view = bmLook([0, 0, 3], [0, 0, 0], [0, 1, 0]);
const proj = bmPersp(1, cv.width / cv.height, 0.1, 100);
// The uniform block is a flat Float32Array. Offsets are in the comment
// above Cube in dist/shaders.js.
const u = new Float32Array(Cube[3] / 4);
u.set(bmMul(proj, bmMul(view, model)), 0); // uMvp
u.set(model, 16); // uModel
u.set([0.5, 0.8, 0.6], 32); // uLight
bmUniforms(p, u);
bmDraw(p);
});
});
Every function it calls is one of about twenty — bmProgram, bmAttr, bmDraw, bmLoop, and the mat4 helpers. They fit on one screen: the whole API reference. Uniforms are a flat Float32Array, since names would cost bytes; the float offset of each one is written as a comment above its shader in dist/shaders.js.
And the page it draws into. The <script src=g.js> at the end is a placeholder — nothing ever builds a g.js. Step 4 replaces that whole tag with the minified program inlined between <script> tags, so what you zip is this one file.
<!doctype html><meta charset=utf-8><title>js13k</title>
<style>html,body{margin:0;overflow:hidden;background:#0a0a17}canvas{display:block;width:100vw;height:100vh}</style>
<canvas id=c></canvas><script src=g.js></script>
4. Build
Save this as build.mjs. It compiles your shaders, concatenates runtime + shaders + game, minifies the whole program in one pass, inlines the result into a copy of the page, zips it, and refuses to finish if the archive goes over 13,312 bytes, printing what you have left when it passes. Plain Node, so it behaves the same on Windows.
// Build a js13k entry: compile shaders, concatenate, minify, zip, and refuse to
// finish if the result is over budget.
//
// The size gate is the point. Knowing you have 9 kB left changes what you build
// next; finding out on submission day does not.
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync, rmSync, statSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(fileURLToPath(import.meta.url));
const dist = join(root, 'dist');
const LIMIT = 13312; // js13k: 13 * 1024
/** Path to a specific brometal CLI. Unset means the installed one. */
const CLI = process.env.BROMETAL_CLI;
function run(cmd, args, opts = {}) {
try {
return execFileSync(cmd, args, { cwd: root, stdio: 'pipe', ...opts });
} catch (error) {
// Without this the tool's own message is swallowed and the failure surfaces
// later as a missing file, pointing at the wrong thing entirely.
process.stderr.write(String(error.stdout ?? ''));
process.stderr.write(String(error.stderr ?? ''));
throw error;
}
}
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
// 1. Shaders → dist/brometal.js + dist/shaders.js
if (CLI) {
run('node', [CLI, 'prod', '--js13k', root]);
} else {
run('npx', ['brometal', 'prod', '--js13k', root]);
}
// 2. One program: runtime, then shaders, then the game.
for (const required of ['dist/brometal.js', 'dist/shaders.js']) {
if (!existsSync(join(root, required))) {
console.error(
`\n\u2717 ${required} was not produced.\n` +
' The installed brometal does not support --js13k. It needs a version\n' +
' that ships the js13k runtime; check the version in package.json.',
);
process.exit(1);
}
}
const combined = ['dist/brometal.js', 'dist/shaders.js', 'src/game.js']
.map((f) => readFileSync(join(root, f), 'utf8'))
.join('\n');
const rawPath = join(dist, 'raw.js');
writeFileSync(rawPath, combined);
// 3. Minify the whole thing at once. --toplevel is what lets the mangler rename
// the runtime's API and drop the parts this game never calls; without it those
// names survive at full length.
const outPath = join(dist, 'g.js');
run('npx', [
'terser', rawPath,
'--compress', '--mangle', '--toplevel',
'--format', 'comments=false',
'-o', outPath,
]);
// 4. Inline the script into the page. One file rather than two: a js13k entry
// is judged as a zip, and every extra member carries its own header and central
// directory record — so two files cost more than the same bytes in one. It also
// makes the result openable straight from disk, since file:// is a secure
// context and WebGPU works there.
const page = readFileSync(join(root, 'src', 'index.html'), 'utf8').replace(
/<script src=g\.js><\/script>/,
// Escaping the closing tag guards the case where minified code contains it
// inside a string, which would end the block early and truncate the game.
() => `<script>${readFileSync(outPath, 'utf8').replace(/<\/script/gi, '<\\/script')}</script>`,
);
writeFileSync(join(dist, 'index.html'), page);
// 5. Zip — js13k measures the archive, not the files.
let zipBytes = null;
try {
run('zip', ['-9', '-q', '-j', 'game.zip', 'index.html'], { cwd: dist });
zipBytes = statSync(join(dist, 'game.zip')).size;
} catch {
// No zip binary (Windows, minimal CI image). Fall back to the raw total so
// the build still reports something honest rather than silently passing.
zipBytes = null;
}
const jsBytes = statSync(join(dist, 'index.html')).size;
const measured = zipBytes ?? jsBytes;
const label = zipBytes === null ? 'index.html (no zip binary)' : 'game.zip';
writeFileSync(
join(root, '.size.json'),
JSON.stringify({ js: jsBytes, zip: zipBytes, limit: LIMIT }, null, 2),
);
// The concatenated and minified intermediates have served their purpose; the
// deliverable is one file. Leaving them invites shipping the wrong thing.
rmSync(rawPath, { force: true });
rmSync(outPath, { force: true });
const pct = ((measured / LIMIT) * 100).toFixed(1);
console.log(` index.html ${jsBytes} bytes`);
if (zipBytes !== null) console.log(` game.zip ${zipBytes} bytes`);
console.log(` budget ${measured} / ${LIMIT} (${pct}%)`);
if (measured > LIMIT) {
console.error(`\n✗ over budget by ${measured - LIMIT} bytes (${label})`);
process.exit(1);
}
console.log(`\n✓ ${LIMIT - measured} bytes remaining`);
npm run build
--toplevel is what pays: it renames the runtime’s functions and drops every one you never call. Inlining into the page saves another ~150 bytes, since a zip charges per file — which is why that one dist/index.html is the whole deliverable. It opens straight from disk, since file:// is a secure context and WebGPU works there.
The zip step shells out to the zip binary. Without one, the build still completes and still enforces the budget, measuring the un-zipped page instead — a number always larger than the archive, so it errs toward telling you that you are over.
Built for coding agents
Most of these entries get written with an assistant, and a graphics library is a hard thing to hand one: it will confidently produce GLSL that never compiles, or WebGL calls for a WebGPU runtime. So the npm package ships what an agent needs to get it right.
CLAUDE.md and AGENTS.md are installed into node_modules/brometal — the DSL rules, and more usefully the mistakes that fail silently: reserved words that produce a blank canvas, sampling a texture inside an if, the row order of a render target. An agent reads them the way it reads any other file in your project.
43 shaders and 19 complete demos ship alongside them, in node_modules/brometal/examples — every example from this site as real, compiling source rather than documentation snippets. Point an assistant at one and ask for something similar.
The compiler closes the loop. This is the part that matters most for an agent: a wrong uniform name, a mismatched vector width or an unsupported construct is a build error with a file and a line, not a black screen. That is a signal it can act on and iterate against — which is exactly what a blank canvas and an empty console are not.
Requirements
Shaders compile to WGSL, so this needs WebGPU. It ships on by default here:
| Chrome, Edge | 113+ |
| Firefox | 141+ |
| Safari (macOS) | 26+ |
| Android — Chrome | 121+, on Android 12 or newer |
| iOS, iPadOS | 26+ |
On iOS the browser makes no difference. Every browser there runs on WebKit, so Chrome and Firefox on an iPhone are Safari underneath — the iOS version is the only thing that decides it.
A browser version is often tied to the OS, so someone on an older machine cannot simply upgrade. And a link opened inside another app — a chat client, a social feed — usually runs in an embedded web view rather than the real browser, which frequently lacks it.