Use Webwin in your project
Everything the demos are built on ships as a library beside them: a game core that draws with three.js, and a windowing front-end that lays windows, controls and a HUD over it. Copy the files, or load them straight off this site. The one thing your page brings is three.js itself.
The files
| File | What it is |
|---|---|
| webwin.js | ES module — the game core: Game, the renderer, the loop, input, images |
| webwin-ui.js | ES module — the windowing front-end (webwin/ui): desktop, windows, controls, themes, flares |
| webwin-markup.js | ES module — the XML layout language (webwin/markup) |
| webwin-gaming.js | ES module — the HUD widget set (webwin/gaming) |
| webwin-engine2d.js | ES module — the 2D scene graph and sprite renderer |
| webwin-engine3d.js | ES module — three.js scenes as game layers, model loading, 3D physics |
webwin-shared-*.js |
Code the entries above share. Not imported directly — but copy them |
| webwin.global.js | Classic script — defines window.Webwin: core and UI, three.js included |
| types/ | TypeScript declarations, mirroring the source tree |
webwin-shared-*.js chunks, which they import by relative path. That
sharing is what makes a gaming widget and a core control the same class at
runtime, so instanceof tells the truth.
The files here are Webwin 0.6.0. A vendored copy keeps
saying so: every bundle opens with the release in its banner comment, and the library
exports it as VERSION, which is worth putting in a bug report.
Building with Claude
webwin.skill is an agent skill for Claude Code. It carries
the mental model, a reference for each part of the API, and a worked application to
read. Most of it, though, is the list of things here that go wrong quietly instead of
throwing, because that is what a model guesses worst: it will happily set a splitter
position before the splitter has a size. Drop the folder inside it into your
.claude/skills directory. The file is an ordinary zip, so unzipping it by
hand works as well as any installer does.
As ES modules
The ES bundles import three.js by its usual names rather than carrying a copy, so a game that builds its own scenes uses the same three.js Webwin does. Without a bundler, an import map says where it is:
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/webgpu": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.webgpu.js",
"three/tsl": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.tsl.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"
} }
</script>
An application that is all windows makes a desktop, which runs a game of its own:
<canvas id="desktop"></canvas>
<script type="module">
import { Desktop, ModernTheme, Window, Button }
from "https://jetsonsoft.com/Webwin/lib/webwin-ui.js";
const desktop = await Desktop.create(document.getElementById("desktop"), {
theme: new ModernTheme("light"),
});
const win = new Window({ title: "Hello", x: 40, y: 40, width: 320, height: 180 });
win.controls.add(new Button({ x: 16, y: 16, width: 88, height: 30, text: "OK" }));
desktop.addWindow(win);
</script>
A game starts the game, draws its world as a layer, and lays the windows over it. The desktop is offered every click and key first, and whatever lands on open ground reaches the game:
import * as THREE from "three/webgpu";
import { Game } from "./lib/webwin.js";
import { SceneLayer } from "./lib/webwin-engine3d.js";
import { Desktop, DungeonTheme } from "./lib/webwin-ui.js";
const game = await Game.create(canvas);
const world = new SceneLayer(game.gpu, { bloom: { strength: 0.6 } });
world.scene.add(new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial()));
game.addLayer(world);
const hud = new Desktop(game, { theme: new DungeonTheme() });
game.input.on("keydown", (e) => { if (e.code === "Space") jump(); });
A bundler-based project imports the same files by path. If you re-bundle, do not
mark the package sideEffects: false. The flare and glyph registries
fill themselves at import time, and dropping those modules leaves nothing to look
up.
As a script tag
<canvas id="desktop"></canvas>
<script src="https://jetsonsoft.com/Webwin/lib/webwin.global.js"></script>
<script>
const desktop = await Webwin.Desktop.create(
document.getElementById("desktop"),
{ theme: new Webwin.ModernTheme("dark") },
);
</script>
The global build is the core and the UI in one, with three.js inside it, deliberately. A classic script cannot share code across files, so shipping one per entry would give each its own copy of the shared core, and a page loading two of them would end up with duplicate classes and forked registries. If you need the HUD or an engine, use the ES modules.
TypeScript
Copy lib/types/ alongside the bundles and point at it:
{
"compilerOptions": {
"paths": {
"webwin": ["./vendor/webwin/types/index.d.ts"],
"webwin/ui": ["./vendor/webwin/types/ui/index.d.ts"],
"webwin/markup": ["./vendor/webwin/types/ui/markup/index.d.ts"],
"webwin/gaming": ["./vendor/webwin/types/ui/gaming/index.d.ts"],
"webwin/engine2d": ["./vendor/webwin/types/engine2d/index.d.ts"],
"webwin/engine3d": ["./vendor/webwin/types/engine3d/index.d.ts"]
}
}
}
Layouts as XML
webwin/markup loads a layout written as XML, at runtime. Its one rule is
that a layout carries no coordinates: a size nobody writes is measured,
* divides up what is left, % takes a share, and em
follows the theme's font. So one document survives a resize, a theme swap and a change
of uiScale without naming any of them.
<Window title="{brand}" size="520x360">
<Values>
<Value name="brand">Notes</Value>
<Value name="gutter">8</Value>
</Values>
<VStack padding="{gutter}" gap="{gutter}">
<Grid cols="auto *" rows="auto auto">
<Label row="0" col="0" vAlign="center">Title:</Label>
<TextBox row="0" col="1" name="title"/>
<Label row="1" col="0" vAlign="center">Tags:</Label>
<ComboBox row="1" col="1" items="Work, Home, Ideas"/>
</Grid>
<TextArea name="body" height="*" wrap="true"/>
<HStack hAlign="end" gap="{gutter}">
<Button width="7em" isDefault="true" onClick="save">Save</Button>
</HStack>
</VStack>
</Window>
import { loadMarkup } from "./lib/webwin-markup.js";
const view = loadMarkup(xml, {
desktop,
handlers: { save: () => store.save() },
});
view.get("title").text = "Untitled";
Names do the binding. name gives code a typed handle on a control, and an
on* attribute names a function in handlers. Attributes hold no
expressions, so there is nothing between the two halves to keep in sync, and a handler
name nobody passed is a load-time error rather than a button that silently does nothing.
_File rather than &File, because a
caption that reads &File in the source is one nobody can scan.
The Markup page is the whole of it running: edit the document on the left and the preview on the right is rebuilt as you type, with a parse error reported at its line and column.
Capturing a frame
The desktop can hand back what it just drew — as a PNG, as a
data: URL, or as raw RGBA8 pixels with no encoder in the way:
const png = await desktop.capture({ maxWidth: 1600 }); // a Blob
const url = await desktop.captureDataUrl(); // survives page.evaluate
const raw = await desktop.capturePixels(); // for a golden-image diff
A page-level screenshot of the canvas works too, and matches these to within a channel or so. What it cannot do is pin a chosen frame, which is the difference between a golden-image test that is stable and one that is flaky. It also does not exist at all inside an embedder such as Electron, a Node harness, or an offscreen canvas in a worker. Each call draws the frame again into a target of its own and reads that back, so it works on either backend and on a phone that will not hand out its swapchain.
Before you start
Webwin draws with WebGPU where the browser has it and falls back to
WebGL 2 where it does not, on its own. Only where neither is available
do Game.create and Desktop.create reject, with
GpuUnavailableError, and your app has to catch that and show a fallback of its
own, the same way every page here does.
import { GpuUnavailableError } from "./lib/webwin.js";
import { Desktop, ModernTheme } from "./lib/webwin-ui.js";
let desktop;
try {
desktop = await Desktop.create(canvas, { theme: new ModernTheme("dark") });
} catch (err) {
showFallback(err instanceof GpuUnavailableError ? err.message : String(err));
return;
}
// Glyphs are cached by face, so the first raster must use the real font.
await document.fonts.ready;
Two smaller things. The canvas owns its own input and text rendering, so do not try
to overlay DOM widgets on it. And glyph rasterisation caches by font face, so
await document.fonts.ready before the first frame if your page uses a
webfont — otherwise the atlas caches the fallback.