A game workshop with Claude living in it.
Three panes. On the left you ask for something. In the middle is the code, with every line that changed marked. On the right the game is already running it. The loop between having an idea and watching it move is about as short as it can be made.
One file. No build step. Nothing to install into your game.
Everything a game gets is a single object called G —
drawing, input, sound, video, saving, scenes, tweens, particles, layers. It
lives in one readable file in your project, which means you can open it, and
change it, and it will still be there next year.
// the whole of a game that does something
G.watch('sparks', () => sparks.length)
function update(dt) {
const pull = G.tune('pull', 1.0, {min: 0, max: 3})
for (const s of sparks) s.a += s.s * dt * pull
}
function draw() {
G.clear('#08080b')
for (const s of sparks) {
G.circle(x, y, s.z, '#a78bfa')
}
}
| Runtime | One file, about 3,700 lines, plain JavaScript |
|---|---|
| Engine API | 22. Carried per project, and pinnable |
| Renderer | Canvas 2D, plus cached offscreen layers |
| Build step | None. Save the file, the game reloads |
| Dependencies | None in the game. Nothing fetched at runtime |
| Input | Pointer events — mouse, pen and touch, all three buttons |
| Sound | Decoded once, played through Web Audio |
| Video | A clip in assets/ is a moving sprite |
| Export | One HTML file, or a folder with real assets |
| Desktop app | Windows. Built on Electron |
Most of the engine is about shortening the gap between a question and an answer.
Change a number while the game is playing
Any number a game passes to G.tune() becomes a slider in the
toolbar. Drag it and the running game changes under your hand — no reload,
no losing your place. Keep the value and it is written next to the project;
discard it and the code's own default stands. Nothing is ever rewritten
behind your back.
A profiler that names the fault
“It feels slow” is not something anyone can act on. perf
runs the real game for a few seconds and writes a report: frame times, how
many device pixels get shaded per frame against how many the canvas has, and
a ranked list of what to look at — blurred fills, gradients rebuilt every
frame, art scaled down from far larger sources. Mark up
draw() and it breaks the frame down by section.
The engine describes itself
state writes a snapshot of the running game:
frame rate typical and at the slow end, whether the engine has already
dropped render resolution to keep up, the save key by key, what is loaded,
how much is alive, and every error thrown with a plain-language read on what
it means. Games add their own numbers with G.watch(). It is also
captured the instant anything throws — so an error arrives with the state it
happened in, not with whatever the game looks like after two reloads.
Every turn is a checkpoint
The whole project is snapshotted before each change — every file, at any depth, not just the ones in the root — and the diff view shows exactly which lines moved. If a turn went somewhere you did not want, one button puts it back. Nothing about that requires you to know what git is.
It plans for a machine that is not yours
A canvas costs roughly what it covers in device pixels, and that is the screen's density squared — a laptop at 150% is asking for 2.25× the work for a picture most people cannot tell apart in motion. So the engine watches its own frame times and quietly renders at a lower density rather than dropping frames, and says in state that it has, rather than leaving you to wonder why it looks softer on the laptop.
Art and sound go in through the conversation
Drop a PNG or an MP3 into the chat and it lands in the project's assets, reachable by name. Generated images arrive the same way. The export packs them as real files beside the page rather than as base64 wedged into the HTML, which is smaller and starts decoding sooner.
The knob is the code.
G.tune('pull', 1.0) reads as an ordinary number to the game and
appears in the toolbar as a slider. There is no second copy of the value to
keep in sync, and no settings file to forget about.
update() and
draw() printed further up this page, running, with the real
pull knob under them.
Sound, video, layers, and a save you can trust on somebody else's machine.
Most of these did not begin as features. They began as an evening lost to something that failed without saying so — which is why each one ships with a way to ask what happened.
Sound that cannot be refused
A file a game adds is decoded once and played through Web Audio rather than
handed to an <audio> element. A media element needs a URL,
and the preview's sandboxed frame has an opaque origin where
data: and blob: URLs are both refused outright —
there was no URL left to try. Web Audio takes bytes, and bytes cannot be
refused. G.play() hands back a handle worth keeping: loop, seek,
duration, an ended event, and a volume that ramps instead of
clicking. G.beep() never once failed while every file a game
added was silent, which was the clue, read backwards, for two days.
Video, and a way to tell four failures apart
A clip in assets/ is a moving sprite —
G.drawVideo() starts it, loops it, draws the current frame. The
hard part is that a <video> which fails does it in silence:
nothing thrown, readyState stuck at zero, a blank rectangle
indistinguishable from a game drawing the wrong rectangle.
G.videoStatus() separates the four — no such asset, no decoder,
fetched but undecodable, nothing at all — because each has a different fix,
and carries the browser's own message through rather than paraphrasing it.
G.videoCheck() answers the one question a broken clip cannot
answer about itself.
Draw it once instead of sixty times
A real profile here came back as 1.7ms of JavaScript against 22.3ms of
rasterisation: the logic was free, the pixels were the whole cost, and most of
those pixels were a background that had not changed in ten minutes.
G.layer() keeps an offscreen canvas and runs your drawing only
when you mark it dirty; every other frame is one copy. It is a trade, not a
free win, and the engine says so — G.layerStats() puts redraws
next to frames, and a layer redrawn every frame is slower than no layer at all.
The save on a stranger's machine is a contract
Save keys are namespaced per project, because itch.io serves every HTML game
from one shared origin — publish a second game on a generic key and it reads
the first one's save, fails to understand it, and overwrites it. Storage is
probed at boot, so a private window or a full quota says so instead of looking
like a new player. Declared migrations carry an old save forward a step at a
time and abandon the lot if one throws, because a half-migrated save is worse
than an old one. G.exportSave() hands the whole thing over as one
pasteable string, for the player who changes laptop.
A project decides which engine it is on
Every project carries its own copy of engine.js. Upgrading on open
is right for a game being started and wrong for one being finished — an engine
that moves underneath you is one more variable in a bug hunt. So a project can
be held at the version it works on, or moved to any version it has ever run,
and the switch scans the game first to name every G. call the
target engine does not have.
Built in the open, not released yet.
The proof that it works is on the games page, playable in your browser, exported from this engine with one button.