When you test a browser game on your Mac, Callout can take more than your words. You add one script tag to the game's index.html. Then, when you press the hotkey while the game is in front, Callout also takes:
- the game's own frame, as the game drew it;
- where your mouse was on the game, and what is under it;
- the console errors and warnings since the page loaded;
- the game's state, such as the screen, the level and the player's place;
- a save of that moment, so whoever fixes the bug can load it;
- a clip of the seconds before you pressed the key and while you talked.
The frame, the mouse position, the console and the clip come from the script tag alone. What is under the mouse, the state and the save come from an optional hooks file that you write.
What you need
- Callout runs on the same Mac as the browser.
- The game runs from a local dev server, and you open it at
localhost,127.0.0.1or[::1]. Any port works. - The game is a project in Callout. Its id is the short name in the first column of
callout projects. - For the frame, the mouse and the clip, the game draws to a
<canvas>. The console, the state and the save work without one.
Step 1: add the script tag
Put this line in the <head> of the game's index.html:
<script>if (/^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname)) { import('http://127.0.0.1:4747/beacon.js?project=my-game').catch(() => {}); import('/src/dev/snag-hooks.js').catch(() => {}); }</script>
Put your project's id in place of my-game. 4747 is Callout's port.
Copy it from Settings
Callout can write the line for you. Open Settings › What Callout takes at the key press › Hook a web game, click "Copy script tag", and pick your game's project. Callout copies the line with that project's id and its own port. Paste it into index.html. Add the game as a project first: with no projects, the copied line has no project id.
What each part does
- The test on
location.hostnamelets the line run only when the page comes from this Mac. On your real website the test fails, so nothing loads and nothing changes. - The first
import()loads Callout's game script from Callout.?project=my-gametells Callout which project the game is. - The second
import()loads your hooks file. If the file is not there, the import fails,.catchignores the failure, and the frame and the console still go to Callout. .catch(() => {})keeps the game working as usual when Callout is not running.
The game script starts to listen for the console when it loads. A console line from before that moment is not in the list. That is why the line goes in the <head>.
Step 2: add a hooks file (optional)
Create src/dev/snag-hooks.js. In it, set window.snagHooks to an object with up to three functions: state(), pick(x, y) and save(). Each one is optional. Each one may return a promise, and Callout waits for it.
Callout waits for each promise with no time limit. A promise that never settles stops the frame, the console, the state and the save for that capture. So keep each hook fast, and make sure it always returns.
If a hook throws an error, Callout notes the error and goes on with the rest. Your hooks must only read the game. They must not change it.
state()
Return a plain object with the facts that a person who fixes a bug asks for first: the screen, the level, the seed, the player's place and health, an open menu. About a dozen short values is enough.
Callout saves the whole object as a JSON file with the snag. The model that writes up the snag gets only the short values from it: strings, numbers, true or false, and lists of them, at the top level and one level down. For example, { player: { x: 412 } } becomes the fact player.x 412. Values deeper than that stay only in the file.
If you do not write state(), Callout sends the page's path and the canvas size.
pick(x, y)
Callout calls pick with the mouse position when you pressed the key. Return what is under that point, in a few words, as a string: "oak door at tile 412, 88".
x and y are pixels of the canvas's own size (canvas.width by canvas.height), not CSS pixels. If your game works in CSS pixels, scale them first:
const canvas = document.querySelector('canvas');
const box = canvas.getBoundingClientRect();
const cssX = (x * box.width) / canvas.width;
const cssY = (y * box.height) / canvas.height;
Callout calls pick only when the mouse is over the canvas. When the game holds the mouse (pointer lock, as in a first-person game), Callout calls it with the middle of the canvas. A value that is not a string is ignored.
save()
Return a save of this moment as { name, data, how }, or null when there is nothing to save:
name: a short name for the save, such as"meadowlark-save.json".data: the save itself. An object becomes JSON. A string stays as it is.how: one sentence that tells a person how to load the save.
Callout keeps data as a file with the snag, for example ~/Callout/attachments/S-42/save-1.json, and shows how next to it. Callout sets no size limit on the save, because it stays on your Mac. The model that writes up the snag never gets the save.
Pack the save the way your game packs its own saves, but do not write it to the game's storage.
A complete example
This example is for an invented game called Meadowlark. canvasToWorld, thingAt, toSave and loadSave stand for your own game's code.
// src/dev/snag-hooks.js: Callout's hooks for Meadowlark.
// Only the localhost script tag in index.html loads this file.
// Each hook reads the running game through window.__meadowlark and changes nothing in it.
const game = () => window.__meadowlark || null;
function state() {
const g = game();
if (!g) return { screen: 'loading' };
return {
screen: g.screen, // "menu", "playing" or "paused"
level: g.level ? g.level.name : null,
seed: g.seed,
player: g.player ? { x: Math.round(g.player.x), y: Math.round(g.player.y), hp: g.player.hp } : null,
enemies: g.enemies.length,
};
}
// x and y are pixels of the canvas's own size.
function pick(x, y) {
const g = game();
if (!g || !g.level) return null;
const at = g.camera.canvasToWorld(x, y);
const where = `${Math.floor(at.x)}, ${Math.floor(at.y)}`;
const thing = g.level.thingAt(at.x, at.y);
return thing ? `${thing.name} at ${where}` : `empty ground at ${where}`;
}
function save() {
const g = game();
if (!g || !g.level) return null;
return {
name: 'meadowlark-save.json',
data: g.toSave(),
how: 'Open Meadowlark on localhost, then run window.__meadowlark.loadSave(<the JSON>) in the console',
};
}
window.snagHooks = { state, pick, save };
With this file, the model that writes up the snag gets facts like these: screen playing · level Meadow 2 · seed 81723 · player.x 412 · player.y 88 · player.hp 7 · enemies 3.
Give the hooks a way into your game
The hooks file is a separate module, so it needs a way to reach the running game. The easy way: in your game's own code, put the game object on window, but only in development.
With Vite:
if (import.meta.env.DEV) window.__meadowlark = game;
Without a build tool, use the same test as the script tag:
if (/^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname)) window.__meadowlark = game;
With Vite, the hooks file can also import your game's own modules.
What Callout sends, and when
- Callout sends game data only for a snag that you speak, with the hotkey or Speak a snag. A snag that you type gets none.
- Only a page that is in front sends data: its tab shows, and it has the focus. A page in a tab behind, or in a window behind, sends nothing.
- If you move to the game's tab while you still talk, the game sends its data at that moment. The snag then says how many seconds after the key press the frame was taken.
- If two pages answer the same capture, Callout keeps the first one.
- First, at the key press: the frame, the mouse position, what
pickreturned, the console, the state and the save, in one message to Callout. - Then, when you stop talking: the clip. It runs from some seconds before the key press until you stopped. With it, the script sends one picture of six stills from the clip, each marked with its time from the key press. If you cancel the capture, no clip goes.
- You set the clip's length in Settings › What Callout takes at the key press › The seconds before the key press: Off, 5 s, 10 s or 20 s. It is 10 s at first.
- To turn off all game data, turn off Settings › What Callout takes at the key press › From your games.
- The game page learns nothing of what you said. From Callout, it gets only these settings, and for each capture its id, its state and the times when it started and ended.
How the script takes the frames:
- It takes each frame right after the game draws it, in
requestAnimationFrame. So a WebGL canvas works withoutpreserveDrawingBuffer. - It takes the biggest canvas on the page.
- The frame at the key press is a PNG at the canvas's full size.
- The clip has up to 10 frames a second. A canvas wider than 1280 pixels is made smaller for the clip.
- A worker encodes the frames, so the game's frame time barely changes.
The script does not draw on the game, pause it or change its state. To watch the game, it wraps requestAnimationFrame, console.error and console.warn, and it listens for mouse moves and errors on the page. Every call goes on to the browser unchanged.
The console list holds console.error and console.warn lines, errors that nothing caught, promise rejections that nothing handled, and files that failed to load. A line that repeats counts up. It does not show again.
Where the data goes
Callout adds the game's files to each snag that the capture made for that game. Each snag's files are in ~/Callout/attachments/S-42/: the frame, the console as text, the state, the save, the clip, and the six stills.
The model that writes up the snag sees the frame, the mouse position and what was under it, the console lines and the short state facts. It can crop the part of the frame that shows the problem, and it quotes console errors exactly.
callout show S-42 prints the path of each file and how to load the save. So Claude Code can open the frame, read the console and load the save when it fixes the snag.
Frameworks
Plain HTML
Serve the folder with any local server, and open the game at localhost. For example, run python3 -m http.server 8000 in the game's folder and open http://localhost:8000. The path /src/dev/snag-hooks.js starts at the folder that the server serves. Do not open index.html as a file: a file:// page has no host name, so the line does nothing.
Vite
index.html is in the project root, beside package.json. Put the hooks file at src/dev/snag-hooks.js in the project root. The Vite dev server serves it at /src/dev/snag-hooks.js.
If your Vite config sets base, start the path with it. For example, with base: '/meadowlark/', use import('/meadowlark/src/dev/snag-hooks.js').
vite build copies the line into the built index.html as it is, and it does not bundle the hooks file. On your website the host name test fails, so neither file loads. vite preview on localhost loads Callout's game script but finds no hooks file, so only the frame and the console go to Callout.
Any other setup
The hooks path is a URL on your dev server, so the file can be anywhere that the server serves. Change the second import() to match. Only the host name test and Callout's address must stay as they are.
Troubleshooting
- Is the page connected? Open Settings › What Callout takes at the key press. Under "From your games", "Listening now" names each game page that has loaded Callout's game script. Settings reads this list when it opens, so open Settings again after you load the game.
- "Listening now" shows the id from the tag, not your project's name. That id is not a project in Callout. Compare it with
callout projects, or copy the line again with "Copy script tag". - Nothing connects. The browser must run on the same Mac as Callout. Open the game at
localhost,127.0.0.1or[::1]. A network address such as192.168.1.20, or a custom host name, fails the host name test. Callout's server lets only pages fromlocalhost,127.0.0.1or[::1](on any port) load its game script, so another origin is refused too. - No game data on a snag. The game's tab must be in front and have the focus when you press the key. Typed snags get no game data. Check that "From your games" is on.
- The frame is blank. This can happen when the game stops drawing, for example on a pause screen. Then the script takes the canvas as it is, and a WebGL canvas without
preserveDrawingBuffercan come out empty. - There is no clip. The game must call
requestAnimationFrameby its global name for each frame. A game that kept its own copy of the function from before the script loaded is not seen. Also check that the clip's length is not Off. - No state, mouse target or save. Check the hooks path in the browser's network panel. The file must set
window.snagHooks. If a hook never returns, nothing from the game goes to Callout for that capture. - A test tool drives the page. A page that Puppeteer, Playwright or WebDriver drives does not answer captures.
