vibeg-kit
v1.1.0
Published
Make web games that play on every screen and device: a full-window canvas framed for any shape, keyboard, mouse, gamepad and touch through one input API, on-screen controls, device-aware prompts, menus, sound and music. The device layer of the vibeg.lol g
Maintainers
Readme
vibeg-kit
Make web games that play on every screen and every device: a computer with keyboard, mouse or gamepad, a phone held upright or sideways, a tablet, an ultra-wide monitor. vibeg-kit is the device layer the vibeg.lol games are built on:
- Screen: a full-window canvas at the device's pixel ratio, framed for any shape (no letterbox), a HUD layer that scales with the view, a frame-rate guard for slow phones.
- Input: keyboard, mouse, gamepad and touch through one API (
input.pressed('A')), an analog stick, the pointer, every finger, the wheel. - Touch: gestures on the game itself (tap, hold, halves, swipes, flicks, drags) and on-screen sticks and buttons where a game needs them.
- Prompts: "Press Space to start" / "Tap to start" / "Press Ⓐ to start", switching by itself when the player picks up another device.
- Menus, sound and music, save / load, pause when the tab is hidden.
It works with any engine. createGame is the Three.js version; createCore is the same without a renderer, for a 2D canvas, Phaser, PixiJS, Babylon or your own.
npm install vibeg-kit three # three only for createGameThe package is plain ES modules with CSS imports: use it with a bundler that handles them (Vite, esbuild, Parcel, webpack with a CSS loader). The examples use Vite.
Quick start (Three.js)
import * as THREE from 'three';
import { createGame } from 'vibeg-kit';
const hh = createGame({ clearColor: 0x87ceeb, touch: { tap: 'A' } });
const { renderer, input, hud } = hh;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, 1.5, 0.1, 200);
camera.position.set(0, 2, 10);
hh.onResize(() => hh.fitCamera(camera, { fov: 40, aspect: 1.5 }));
hud.innerHTML = `<div class="msg">${hh.prompt('jump', ['A', 'TAP'])}</div>`;
hh.run((dt) => {
if (input.pressed('A')) jump();
if (input.pressed('START')) pause();
renderer.render(scene, camera);
});
hh.onHide(() => pause());index.html needs only a HUD element and the script:
<body><div id="hud"></div><script type="module" src="./src/main.js"></script></body>Quick start (any engine)
import { createCore } from 'vibeg-kit/core';
const canvas = document.createElement('canvas');
const hh = createCore({ canvas, touch: { swipe: true } }); // the kit sizes the canvas
const g = canvas.getContext('2d');
let x = 0;
hh.run((dt) => {
x += hh.input.stick.x * 300 * dt;
g.setTransform(hh.view.pixelRatio, 0, 0, hh.view.pixelRatio, 0, 0); // draw in CSS px
g.clearRect(0, 0, hh.view.width, hh.view.height);
g.fillRect(hh.view.width / 2 + x, hh.view.height / 2, 40, 40);
});An engine that sizes its own canvas passes sizeCanvas: false and resizes in onSize (see "Engine adapters").
Rules for games that work on the web and on phones
These are the rules the vibeg.lol games follow. A coding agent building a game with the kit should keep to them too.
- Fill the screen, any shape. Frame the scene in
hh.onResize(hh.view,hh.fitCamera,hh.fitBox): wide screens see more of the world, tall phones get more above and below or their own framing. Never letterbox, never stretch; build scenery wide and tall enough for a 21:9 monitor and a tall phone. Spawn things where the view is, not at fixed numbers. - Never touch the window yourself. No
resize, keyboard, mouse, pointer or touch listeners, nosetPixelRatio, noinnerWidth. Read input throughhh.input; the view throughhh.view. - One loop. Everything happens in
hh.run((dt) => ...), moved bydt(seconds). NosetInterval,setTimeoutor ownrequestAnimationFrameloop. - Every device feels native. The keys a PC player expects (below), the mouse wherever pointing fits, and on a phone the gestures a mobile game of that kind uses: tap, hold, swipe, drag. Use on-screen sticks and buttons only where the game needs them, and as few as possible.
- Conventions. A confirms, jumps or fires; B goes back; START pauses. Menus also take a click or tap: start with A, START or a tap; resume with START or a tap; play again with A or a tap.
- Name buttons only through the kit. Never write "Press Space" or "Tap" into the HUD:
hh.prompt,hh.labelandhh.textname the right thing for the device in use. - HUD that fits a phone. Size it with
calc(N * var(--kit-u)), anchor pieces to the edges, keep it out ofvar(--kit-bottom),var(--kit-top)andvar(--kit-pause)(the touch controls and the corner buttons), and cap wide boxes withmax-width: calc(100vw - 24px)so they fit a 390 px wide phone. Text at least 18 px on a phone. Change the DOM only when a value changes. - Pause when the player leaves:
hh.onHide(() => { if (playing) pause(); }). - Fast on a mid-range phone: 60 fps, at most 200 draw calls and 300k triangles per frame (
?perfshows them, red over budget). Nothing created per frame; pools and instancing for repeated things. - Self-contained. Bundle everything (
npm install three, not a CDN link); load assets with relative paths.
Screen
createGame(options) (Three.js) and createCore(options) (any engine) return hh, the game's handle.
| Option | |
|---|---|
| touch | the touch layout (see "Touch") |
| keys | changes to the key map: { KeyZ: 'B' }, null removes a key |
| maxPixelRatio | highest devicePixelRatio used (default 2) |
| stickDpad | false: the gamepad's left stick does not press the d-pad buttons (read input.stick / input.padStick) |
| wheel | true: the mouse wheel over the game never scrolls the page (for games that use input.wheel) |
| clearColor, antialias | createGame only |
| canvas, sizeCanvas, onSize, frame | createCore only (see "Engine adapters") |
- The canvas fills the window (or the iframe it runs in) at
devicePixelRatio, at mostmaxPixelRatio. A frame-rate guard lowers the resolution a step after two slow seconds. hh.view:{ width, height, aspect, portrait, pixelRatio, insets, unit, top, pause, controls, swipe }in CSS px.insets: the parts covered by touch controls (in portrait with on-screen controls,insets.bottomis the strip they take).unit,top,pause: the HUD variables below as numbers, for layout done in JavaScript.controls: the on-screen controls' rectangles,{ kind, button, mode, l, t, r, b }(kind'pad','stick','button'or'pause'); empty when they are not shown.swipe: the swipe distance in px.
hh.onResize(fn):fn(view)now and whenever the size changes or the touch controls appear or go. Frame the camera there.hh.freeArea(): the part of the view beside the touch controls,{ l, t, r, b, w, h, cx, cy }.hh.covered(gap = 0): the rectangles the kit's own buttons cover (the touch controls and, on touch screens, both top corners), each grown bygap. Keep HUD and the important part of the scene out of them.hh.width,hh.height: the view size.
Framing a camera (Three.js)
hh.fitCamera(camera, opts)keeps a design frame fully visible in the free area and shows more of the world around it:- Perspective:
{ fov, aspect }is the frame (the vertical fov you designed for, at that aspect). Wider views see more to the sides, taller views more above and below. PastmaxFov(75) the fov is capped and the call returns how much farther the camera must move to keep the frame (1 otherwise):camera.position.z = DIST * hh.fitCamera(camera, { fov: 40 }). - Orthographic:
{ width, height }in world units around the camera's centre line. insets: falseuses the whole view.
- Perspective:
hh.fitBox(camera, box, opts)makes a box (a board, an arena, a table) as big as the screen allows, clear of HUD boxes and the kit's controls. The camera keeps its position and direction; the field of view (or the orthographic frustum) and a shift of the image change.camera.position.set(0, 20, 14); camera.lookAt(0, 0, 0); hh.onResize(() => { const score = hud.querySelector('#score').getBoundingClientRect(); hh.fitBox(camera, new THREE.Box3(new THREE.Vector3(-6, 0, -4), new THREE.Vector3(6, 1, 4)), { avoid: [score] }); });boxis aTHREE.Box3,{ min, max }or a list of points. Options:area({ l, t, r, b }, default the free area lessmargin),avoid(rectangles:{ l, t, r, b }or DOMRects),margin(default8 * view.unit),gap(default6 * view.unit),covered: falseto ignore the kit's controls. Returns{ s, cx, cy, rect }: px per unit, the box's centre on screen and its bounding rectangle on screen (place HUD next to it).- For another engine, the same math is exported as plain functions:
perspectiveFit(view, opts),orthographicFit(view, opts)andcreateFit(points)withfitCameraNumbers(fit a shape given in camera "tan space",[x / depth, y / depth], into a rectangle around others).createFitgivessolve(area, avoid)(the biggest fit,{ s, cx, cy }),room(fit, area, avoid, 'x' | 'y')(how far it can slide),rect(fit),overlaps(fit, rect)andextremeX(fit, y0, y1, side)(the shape's leftmost or rightmost x between two rows, to stand HUD right beside it). A game that picks between camera poses or HUD layouts (Tank Brigade) uses these directly.
HUD
hh.hud is a full-window layer over the canvas (pointer-events: none; elements with data-kit-btn or data-kit-item take clicks and taps). Size and place everything with the kit's CSS variables:
--kit-u: 1px on a 720x480 view, scaled with the view, between 0.75px and 1.6px. Write sizes ascalc(24 * var(--kit-u)).--kit-bottom: the height the touch controls take at the bottom (portrait).--kit-pause: the width the touch pause button takes at the top right.--kit-top: on touch screens, the height of the buttons in the top corners (the kit's pause button at the right; on vibeg.lol the site's close button at the left; 0 elsewhere). HUD that spans the top starts below it:padding-top: max(calc(34 * var(--kit-u)), var(--kit-top)).
URL switches: ?perf shows fps, resolution, draw calls and triangles (red over budget); ?touch forces touch mode and lets the mouse act as a finger; ?keyboard forces keyboard mode; ?mute turns sound off; ?cover hides prompts and controls for a cover screenshot (hh.cover is true, html.kit-cover is set).
Input
hh.input:
input.down(b),input.pressed(b)(this frame),input.released(b).input.presses: every button pressed this frame, in the order they went down. Two presses within one frame (an arrow and A) are both there, in order.input.dpad.x/.y: -1, 0 or 1 (y is -1 for up).input.stick{ x, y }, -1..1 per axis: the touch stick, else the gamepad's left stick (dead zone rescaled; past half-way it also presses the d-pad unlessstickDpad: false), else the d-pad and keys. The axes are independent; normalise the vector yourself for free movement.input.padStick: the gamepad's stick alone.input.pointer: the mouse, or the first finger on the game.x,yin CSS px;nx,nyin -1..1 (ny up) forraycaster.setFromCamera({ x: p.nx, y: p.ny }, camera);down,pressed,released;dx,dy(movement this frame);startX,startY(where the press began);active(over the game);type('mouse'or'touch').input.pointers: every finger on the game (not on a control), and the mouse while its button is down: the same fields plusid. A lifted finger stays one frame withreleasedset. For two-finger controls (a plunger while the flippers are held, a floating stick).input.wheel{ x, y }: the mouse wheel or touchpad scroll this frame, in px (y > 0 scrolls down). Passwheel: trueso the page does not scroll too.input.item: the menu entry (data-kit-item) clicked or tapped this frame, or -1 (see "Menus").hh.drag(opts): relative drag, "move by the finger's movement, not to the finger", so the finger never covers what it moves:const drag = hh.drag({ gain: 1.5, min: -6, max: 6 }); // axis 'x' | 'y', types 'touch' | 'mouse' | 'any' // every frame: ship.x = drag.apply(ship.x, 1 / pxPerUnit); // unchanged when nothing drags
Buttons: UP DOWN LEFT RIGHT A B X Y L R START SELECT TAP.
| Button | Keyboard | Gamepad (standard mapping) | |---|---|---| | D-pad | Arrows, WASD | D-pad, left stick | | A | Space, Z, J | 0 (bottom) | | B | X, K, Backspace | 1 (right) | | X / Y | C / V | 2 / 3 | | START | Esc, Enter, P | 9 | | SELECT | Shift | 8 | | L / R | Q / E | 4 / 5 and triggers 6 / 7 |
TAP is a click or tap on the game itself (anywhere that is not an on-screen control). The kit calls preventDefault on its keys (never with Ctrl, Cmd or Alt held), so the page never scrolls. A press shorter than one frame still counts as one pressed frame.
Touch
On-screen controls show only while the player uses touch: from the start on phones and tablets, or once a finger touches the screen on a hybrid laptop; a key or gamepad press hides them. Prefer gestures on the game itself to virtual buttons when they fit the game.
| touch option | What it does (touch, and mouse clicks too) |
|---|---|
| tap: 'A' | A tap or click anywhere presses A, held while down (one-button games). |
| halves: ['LEFT', 'RIGHT'] | The left half of the screen presses the first button, the right half the second. halfLabels: ['◀', '▶'] draws faint labels. |
| swipe: true | Swipes (and mouse drags) press UP / DOWN / LEFT / RIGHT for one frame; a finger that keeps moving swipes again. A short tap presses tap (or the half's button) and TAP, on release. |
| swipe: { up: 'A', left: 'LEFT', ... } | Only the directions named press a button; dpad: true maps all four to the d-pad. |
| swipe: { dpad: true, repeat: false } | One swipe per touch, however far the finger goes (puzzle moves, lane changes). |
| swipe: { ..., flick: true } | A quick flick shorter than a swipe still swipes. distance sets the swipe distance (px). |
| left: [...], right: [...] | On-screen controls at the bottom left and bottom right. |
| pause: false | No pause button (on by default: START, top right). |
Controls in left / right: 'stick' (analog, input.stick), 'dpad' (4-way), 'dpad8' (8-way), 'dpad-x' (left / right only), and buttons: 'A' or { button: 'A', label: 'FIRE', color: '#e8453c' }. The first button of a cluster is the main one (biggest, nearest the corner); up to four per cluster. Every finger is tracked on its own, so a stick and a button work together.
hh.setTouch(config)swaps the layout at runtime (menus vs play).hh.setButtonLabel('X', 'FULL')changes an on-screen button's text (and the prompts that name it) in place, without redrawing it.hh.rotateHint = trueshows a "turn sideways" hint on phones held upright, for a game that really needs a wide screen.
Prompts
Helpers that return HTML with one variant per device; CSS shows the one for the device in use and switches by itself. Put them in the HUD with innerHTML.
hh.prompt(action, buttons = 'A', { hold, pill, asPill }): a whole sentence. keyboardPress <kbd>Space</kbd> to start; gamepadPress Ⓐ to start; touchTap to start(when the list has TAP or the tap-anywhere button),Tap [FIRE] to start(an on-screen button),Swipe ↑ to jump/Tap left to ...(gestures), otherwise a tappable pill that says the action.asPill: truealways makes the pill on touch.hh.label(button): one button inside your own sentence.hh.label('DPAD')givesArrows/D-pad/Swipe.hh.text({ keyboard, touch, gamepad }): free text per device (touch and gamepad fall back to keyboard).hh.hint(button): only the key or pad button, nothing on touch (for something that is a tappable button itself).hh.labelText(button): plain text for the current device.hh.device:'keyboard','touch'or'gamepad';hh.onDeviceChange(fn).
Chips and pills are tappable and clickable: they press their button. Any HUD element with data-kit-btn="A" does the same.
Menus (vibeg-kit/menu)
Title, options and pause menus that work the same with keys, gamepad, mouse and touch.
import { createMenu } from 'vibeg-kit/menu';
const menu = createMenu(hh, { sound }); // sound: optional, plays the menu sounds
const SOUND = [
{ id: 'sfx', label: 'Sound', on: () => sound.sfxOn, set: (on) => sound.setSfx(on) },
{ id: 'music', label: 'Music', on: () => sound.musicOn, set: (on) => sound.setMusic(on) },
];
function pause() {
hud.innerHTML = `<div class="panel"><h2>PAUSED</h2><div class="menu"></div>${hh.prompt('resume', ['START', 'TAP'])}</div>`;
menu.open(hud.querySelector('.menu'), [{ id: 'resume', label: 'Resume' }, ...SOUND, { id: 'quit', label: 'Quit to title', sound: 'back' }], {
back: 'resume', start: 'resume', outside: 'resume', center: 'resume',
});
}
hh.run(() => {
if (paused) {
const id = menu.update();
if (id === 'resume') resume();
else if (id === 'quit') toTitle();
}
});- Entries:
{ id, label }(an action),{ id, label, on, set }(On / Off: "Sound: On"),{ id, label, values, index, set }(a choice: "Board ◀ 4×4 ▶").labelmay be a function returning HTML (for a toggle it gets the state, for a choice the value). Optional per entry:sound(a key ofsounds, a sound name, orfalse),action(),className,wrap(html)(HTML around the entry, for things beside it such as arrow buttons withdata-kit-btn="LEFT"and"RIGHT"; the entry stays the element withdata-kit-item). - UP / DOWN move the highlight (wrapping), A or a click picks, LEFT / RIGHT flip an On / Off entry or step through a choice. Labels change in place.
open(el, entries, opts):sel(the entry highlighted, index or id),back/start/outside(what B, START and a tap or click beside the entries do: an entry id, which acts as picking it, or a function),center(an entry to keep in the middle of the free area: put Resume there, so a tap in the middle of the screen resumes),panel(the elementcentermoves, default the container's parent).update()once per frame returns the id of the entry picked or flipped, or null. Every press of a frame counts, in order: DOWN then A picks the entry below. A tap beside the entries is never A (withtouch: { tap: 'A' }a tap presses both).close(),refresh()(read every label again),center(),sel,id,active.- Options:
sound,sounds({ move, select, back, toggle }, e.g.'pack:move'on vibeg.lol),itemClass/selClass(defaultkit-item/kit-sel),words(['On', 'Off']). - Each entry is an element with
data-kit-item="<index>". You can also build your own:input.itemis the index clicked or tapped this frame.
Sound (vibeg-kit/sound)
Effects and music with Web Audio.
import { createSound } from 'vibeg-kit/sound';
const sound = createSound(hh, { sfx: ['jump', 'coin', 'hit'], music: 'theme' });
sound.play('coin', { volume: 0.8, rate: 1.1, pan: 0, delay: 0 });
const engine = sound.loop('engine');
engine.set(throttle * 0.6, 0.8 + speed); // every frame; set(0) fades it out and stops it
sound.startMusic(); sound.stopMusic(); sound.duck(true); // duck: quieter under menus
sound.setSfx(false); sound.setMusic(false); // saved with hh.save- Files:
public/sfx/<name>.wav(short, mono) andpublic/music/<name>.oggplus<name>.m4afor Safari and iPhones, which cannot decode Ogg (tools/music-m4a.sh <game folder>makes it with ffmpeg). - On vibeg.lol the free sound pack plays by name with nothing to copy:
sfx: ['pack:coin', 'pack:jump'],music: 'pack:adventure', thensound.play('pack:coin'). The names are in the developer docs. - Browsers start audio only inside a key press, click or tap. The kit makes its
AudioContextat the player's first one; files are fetched and decoded before that, so the first sound is ready at once. Inside a player frame that may autoplay (the vibeg.lol player) it starts at load. Gamepad buttons do not count. Audio stops while the tab is hidden. - Loops:
set(volume, rate)starts the loop and glides to the values;set(0)orstop()fades it out;loop.playing;sound.stopLoops()stops them all. Nothing to track. - Sounds the game makes itself:
sound.add(name, audioBuffer)orsound.add(name, float32Samples, { sampleRate }).sound.synth(name, seconds, (ac) => { ...nodes connected to ac.destination })renders Web Audio nodes once, ahead of time (anOfflineAudioContext); resolves true whensound.play(name)can play it.- Live audio through the kit's mixer:
sound.onStart((context, output) => ...)runs once theAudioContextexists; connect tooutputto follow the effects volume and the Sound switch.sound.context,sound.output.
- Clocks:
sound.musicTime(seconds into the music loop, for things on the beat),sound.time(the audio clock). sound.ready,sound.sfxOn,sound.musicOn,sound.has(name).?muteturns everything off.
Online rooms (vibeg-kit/online, vibeg.lol only)
The themed front end of vibeg.lol's online rooms for Three.js games, on the platform SDK (window.vibeg, served at /_vibeg/sdk.js on every game host). Load the SDK in index.html (<script src="/_vibeg/sdk.js"></script>) and declare the game's multiplayer in vibeg.json ("multiplayer": { "players": [2, 4], "referee": { "kind": "race" } }). Players sign in on vibeg.lol to play online. Away from vibeg.lol there is no SDK: the kit shows "Online play works on vibeg.lol." and calls onExit.
import { createOnline } from 'vibeg-kit/online';
const online = createOnline(hh, {
game: 'my-game', // the site slug, for invite links
title: 'ONLINE RACE', // the lobby card's heading
rules: '3 laps · up to 4 players',
start: 'race', // "Start the race"
players: [{ name: 'Red', color: '#e8453c' }, ...], // per slot: the colours
option: { label: 'Circuit', values: ['Bay', 'Canyon'], start: 0 }, // optional, the host picks
quickChat: true, // optional: the platform's quick chat button while in a room
onMessage(type, msg) {}, // 'go', 's', 'e', 'fin', 'ko', 'claim', 'results', 'join', 'left', ...
onExit(reason) {}, // left the room: back to the title
});
online.setOption(circuit); // the option a room opens on (or the host changes it)
if (online.invited) online.join(online.invited); else online.create();
// or online.quickJoin(): an open room with a free place, else a new one on the option picked
// every frame: online.update(); send: online.send({ t: 's', x, y })- The kit draws the lobby and results card (room code, the players' usernames, the host's option, Start / Invite friends / Leave) and handles its input (A start, Y invite, B leave, ← → the option), and shows toasts (
online.toast(text, color)). - Messages:
{ t: 's', ...numbers }(state, up to 25 a second) and{ t: 'e', k: 'name', ...numbers }(events) go to the other players, cleaned to finite numbers under keys of 1-4 lower-case letters and digits;{ t: 'fin' },{ t: 'ko', by }go to the referee;{ t: 'claim', n }takes shared thing n (the first claim wins). - Rooms:
online.create()opens a new room,online.join(code)joins one (an invite link:online.invited),online.quickJoin()puts the player in an open room of the game that takes strangers and has a free place, or opens a new one (the manifest's"public": falsealways opens a new one). A room this player opens starts on the option picked before (option.start,online.setOption(n)). - Quick chat:
quickChat: true(or{ key: 'KeyT' }, the key that opens it; T by default) shows the platform's quick chat button (fixed phrases and emotes,vibeg.quickChat.picker) while the player is in a room: in the bottom right corner, above the on-screen controls there.online.chatAreais its rectangle while it shows (keep HUD clear of it). Taps and keys on it never reach the game. online.me,host,isHost,roster,mode,option,results,nameOf(id),colorOf(id). A dropped connection reconnects by itself within 30 s.- Theme the card on
.kit-online(--kit-ol-accent,--kit-ol-panel,--kit-ol-ink). - A game on any engine can use the SDK directly (
vibeg.create(),room.send(),vibeg.lobby(room)); see the developer docs.
Other
hh.run(update): the loop;update(dt)once per frame,dtin seconds, capped at 1/20.hh.onHide(fn): the tab was hidden, or (on computers) the player clicked outside the game. Pause there.hh.onGesture(fn):fn(event)inside every key press, click and touch, where browsers allow audio to start.hh.save(key, value),hh.load(key, fallback): localStorage as JSON, failures ignored.hh.stats:{ frames, fps, calls, triangles, maxCalls, maxTriangles }(draw calls with the Three.js part).window.__kitis a read-only handle for automated tests (device, stats, view, input).
Engine adapters
createCore takes what an engine needs to plug in; createGame is built this way:
const hh = createCore({
canvas: engine.canvas,
sizeCanvas: false, // the engine sizes its canvas
onSize: (view) => engine.resize(view.width, view.height, view.pixelRatio),
frame: {
schedule: (tick) => engine.onFrame(tick), // default: requestAnimationFrame
before: () => engine.resetStats(),
measure: () => ({ calls: engine.drawCalls, triangles: engine.triangles }),
budget: { drawCalls: 200, triangles: 300000 },
},
});Exports
| Import | |
|---|---|
| vibeg-kit | createGame, BUDGET (Three.js), and everything below except online |
| vibeg-kit/three | createGame, BUDGET |
| vibeg-kit/core | createCore, BUTTONS, createDrag, freeArea, perspectiveFit, orthographicFit, createFit, fitCameraNumbers, swipeDistance |
| vibeg-kit/menu | createMenu, menuEvents |
| vibeg-kit/sound | createSound |
| vibeg-kit/online | createOnline, roomFromUrl, inviteLink |
| vibeg-kit/kit.css, vibeg-kit/online.css | the styles (imported by the modules above) |
In the vibeg repository
The first-party games in games/ use the kit as an npm workspace package ("vibeg-kit": "*"). npm run test:games [id ...] (from the repo root) builds each game and plays it with Playwright: desktop with the keyboard and the mouse, phone emulation upright and sideways with real touch events, and the draw-call budget; the steps per game are in games/<id>/e2e.mjs (games/sky-hopper/e2e.mjs is the example). npm test runs the kit's unit tests (test/kit.test.ts). games/sky-hopper is the reference game: framing per screen shape, a HUD in --kit-u, tap anywhere to flap, the kit's menus and sound.
License
MIT
