On this page
Start
Start here Studio workflow Files & metadata The big picture Your first game
Core concepts
Drawing things Making things change Players & multiplayer Input: taps, buttons, keys Movement & game loops
Building your game
Recipes: "how do I make…" Images & sound Saving progress Gotchas
Shipping
Test & share Publish to the catalog Going further

How to make Homegames games

What a Homegames game is, how it works, and how to build and publish one. Basic JavaScript is the only prerequisite.

Ask a Homegames question (up to 500 characters). This optional model service can be busy or offline; answers are generated from a reference document and may be mistaken. It does not inspect your current game.

Start here

A Homegames game is a JavaScript class exported from index.js, using the Squish framework provided by the runtime. The examples need no build step. Bigger games split into JavaScript modules with relative requires. The game describes a scene, and the runtime renders it in a browser-local or shared hosted session.

To try it:

  1. Open the Studio (no account needed).
  2. Pick a template.
  3. Hit Play.
  4. Change something and hit Play again.

This tutorial uses squish-142. Keep that import and the metadata version together; newer node features require a matching Squish package in both the host and browser runtime. The rest of this page explains the code and how to ship it.

A reliable Studio workflow

  1. Start as a guest. Pick a template, edit its files, and run a local preview. Guest work is a draft in this browser's storage, not an account backup.
  2. Save to an account. Sign up, verify the emailed code using Studio's banner, and choose Save Version. This creates a repository-backed game and carries your guest edits into it. Creating, saving, restoring, uploading, and publishing require a verified account.
  3. Iterate. Preview includes unsaved editor changes. While a preview is running, edits trigger a restart after a short delay; this resets the live game. A successful preview does not mean the edits have been saved or published.
  4. Check the saved result. Save Version writes changed files to Git. A multi-file save can create several consecutive commits. Wait for “Version saved,” then inspect the final version before publishing.
  5. Publish deliberately. Set your game's description and thumbnail, write its README, and submit the saved version you tested. Unsaved changes are not included in a publish request.

Historical versions open read-only and can be previewed. Restore writes the selected version's files back to the current branch through new commits; it does not erase Git history or change which versions are published. If saving fails partway through, preserve your editor text and inspect version history before retrying: earlier files may already have been committed.

Studio also keeps local drafts of unsaved work when browser storage is available. Treat those as recovery help, not a substitute for a successful Save Version. Clearing site data removes local drafts. Archive only moves a game out of your main Studio list; it does not remove published versions from the catalog.

AI Edit: when the server enables this feature, save your changes first. The editor locks while the queued job runs, and a successful edit becomes a new version. Inspect and test that version before publishing. A visible AI button does not guarantee server availability; disabled, busy, and failed jobs have separate status messages.

Files and metadata

Use a root index.js exporting your game class with module.exports = MyGame. Import the supplied Squish alias and your own JavaScript files; the publishing path does not install arbitrary npm dependencies.

// index.js
const { Game, GameNode, Colors, Shapes, ShapeUtils } = require('squish-142');
const { clamp } = require('./rules');

// rules.js (a separate file)
module.exports.clamp = (value, min, max) => Math.max(min, Math.min(max, value));

The website's local-play source map includes JavaScript files. Put data needed by those modules in JavaScript exports and use the asset system for media; do not assume a local JSON file or filesystem read will work in every play mode.

Metadata fieldWhat it means
squishVersion: '142'Required for publishing; match the supplied package alias and use a version supported by both host and client.
name: 'My Game'A nonempty literal name; publishing validates a maximum of 100 characters.
aspectRatio: { x: 16, y: 9 }Positive dimensions for the game viewport. The coordinate plane still runs from 0 to 100 on each axis.
tickRate: 20Target calls per second when you implement tick(). Publishing accepts 1–120; timing is not a real-time guarantee.
services: ['multiplayer']Marks a game for the website's multiplayer controls. It is not a player-count limit or matchmaking implementation.
assets: { ... }Literal keys mapped to new Asset({ id: '...', type: 'image' }) declarations; see assets.
saveCompatibility: ['older-version-id']Opt into loading another version's save on hosts using versioned saves. Only list formats your code can actually read.

Keep metadata() a static method returning an object literal, with literal values for names, versions, services, and asset IDs. The catalog and local-play path read these from source without executing your game; computed values can disappear from that analysis even if they work during a preview. The Studio listing description and thumbnail are separate settings from your code's metadata.

The big picture

Five things to know before the code makes sense. (For the platform-level view — what runs where, how multiplayer sessions and publishing work under the hood — see how Homegames works.)

1. Your code runs inside the Homegames runtime

The runtime creates one instance of your class and keeps it alive for the whole session. Local play runs that instance in the browser; shared play runs it on a host. The same lifecycle and scene APIs work in both, but the environments and available services differ. In either mode:

2. The screen is a 100×100 grid

The visible viewport spans 0 to 100 on each axis; positions can have fractional values. (0, 0) is the top-left corner, (100, 100) is the bottom-right, on every screen size. "A button a third of the way down, half the screen wide" is y: 33, width: 50.

3. Everything on screen is a node in a tree

There are three node types: Shape (polygons), Text, and Asset (an image or sound). They form a tree rooted at one node — usually a full-screen rectangle called this.base that acts as the background. Nodes added later draw on top of nodes added earlier.

4. When you change something, you have to say so

The platform doesn't watch your variables. After changing node properties, call this.base.node.onStateChange() once to push the new state to players. Forgetting this is the most common bug: the code runs but the screen never updates.

5. Players are just numbers

When someone joins, your game gets their playerId. Every tap and key press comes with the acting player's id. You decide what the ids mean — whose ship is whose, whose turn it is. Nodes can also be scoped to specific players for personal views. Read the visibility limitations before relying on this for secret information.

Your first game, line by line

A complete working game: a target you press to score points. Every game has this structure — metadata, constructor, getLayers.

const { Game, GameNode, Colors, Shapes, ShapeUtils } = require('squish-142');
const { COLORS } = Colors;

class TapGame extends Game {

    // Describes your game to the platform. squishVersion must
    // match the number in the require() line above.
    static metadata() {
        return {
            squishVersion: '142',
            name: 'Tap Game',
            author: 'You',
            description: 'Tap the square. Get points.',
            aspectRatio: { x: 16, y: 9 }
        };
    }

    // Runs once when a session starts. Build your starting scene here.
    constructor() {
        super();                     // always call super() first
        this.score = 0;
        this.heldPlayers = new Set(); // avoid counting repeats while held

        // The root of everything: a full-screen background.
        this.base = new GameNode.Shape({
            shapeType: Shapes.POLYGON,
            coordinates2d: ShapeUtils.rectangle(0, 0, 100, 100),
            fill: COLORS.HG_BLUE
        });

        // A text label. size is a relative font size (2-4 is typical).
        this.scoreText = new GameNode.Text({
            textInfo: { text: 'Score: 0', x: 50, y: 10,
                        size: 4, align: 'center', color: COLORS.WHITE }
        });

        // A tappable target. Held presses can repeat onClick.
        this.target = new GameNode.Shape({
            shapeType: Shapes.POLYGON,
            coordinates2d: ShapeUtils.rectangle(40, 40, 20, 20),
            fill: COLORS.CANDY_RED,
            onClick: (playerId, x, y) => {
                if (this.heldPlayers.has(playerId)) return;
                this.heldPlayers.add(playerId);
                this.score++;
                // To change text, replace the whole text object...
                this.scoreText.node.text = { text: `Score: ${this.score}`,
                    x: 50, y: 10, size: 4, align: 'center', color: COLORS.WHITE };
                // ...then tell the platform something changed.
                this.base.node.onStateChange();
            }
        });

        this.base.addChildren(this.scoreText, this.target);
    }

    handleMouseUp(playerId) {
        this.heldPlayers.delete(playerId);
    }

    handlePlayerDisconnect(playerId) {
        this.heldPlayers.delete(playerId);
    }

    // Tells the platform what to render. A single layer is enough here.
    getLayers() {
        return [{ root: this.base }];
    }
}

module.exports = TapGame;

Paste it into a blank game in the Studio and hit Play. Some things to try:

Drawing things

Shapes

A Shape is a polygon: a list of [x, y] corner points. Two helpers cover most cases:

ShapeUtils.rectangle(x, y, width, height)   // a box, from its top-left corner
ShapeUtils.triangle(x1, y1, x2, y2, x3, y3) // any three points

For anything else, write the points yourself: coordinates2d: [[10,10], [90,10], [50,90], [10,10]] is a triangle (the last point repeats the first to close the loop). Stars, arrows, ships, terrain — all point lists.

Useful Shape options:

new GameNode.Shape({
    shapeType: Shapes.POLYGON,
    coordinates2d: ShapeUtils.rectangle(10, 10, 30, 20),
    fill: COLORS.CORAL,               // interior color
    color: COLORS.WHITE,              // outline color; its alpha also controls opacity
    border: 4,                        // outline width — a NUMBER. Setting border
                                      // requires setting color too.
    effects: {                        // a glow! great for neon looks
        shadow: { color: [0, 255, 255, 255], blur: 12 }
    },
    onClick: (playerId, x, y) => { /* tapped! */ }
});

Use polygon circles with this client. A Shapes.CIRCLE constant exists, but the canvas renderer does not draw that representation as an arc. Draw a many-sided polygon instead — 16 sides reads as a circle:

const polyCircle = (cx, cy, r, sides = 16) => {
    const pts = [];
    for (let i = 0; i <= sides; i++) {
        const a = (i / sides) * Math.PI * 2;
        pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
    }
    return pts;
};

Text

new GameNode.Text({
    textInfo: {
        text: 'Hello!',
        x: 50, y: 20,           // where the text sits, 0-100 space
        size: 3,                // 1 = small, 3 = heading, 6 = title
        align: 'center',        // 'left' | 'center' | 'right'
        color: COLORS.WHITE
    }
});

In Squish 142, put button handlers on Shapes. Its Text constructor does not accept onClick. To make a button, put the onClick on a Shape and draw the Text on top of it — see the button recipe.

Colors

A color is [red, green, blue, alpha], each channel an integer from 0 to 255. Alpha 255 is solid, 0 is invisible. There's a named palette on Colors.COLORS (including RED, GOLD, EMERALD, HG_BLUE, and CANDY_PINK), and Colors.randomColor(). For a Shape fade, use node.color[3]: the current renderer applies that channel as overall opacity, while integer fill alpha values above zero render opaque.

Channels outside 0–255 wrap around instead of clamping — an alpha of 400 renders as 144. If you compute a color (a fade, say), clamp it: Math.max(0, Math.min(255, Math.round(v))).

The aspect-ratio stretch

The 100×100 grid is stretched to fit your aspectRatio. At 16:9 an x-unit is wider than a y-unit, so rectangle(0, 0, 10, 10) renders wide and circles render as ovals. Fine for menus and party games. For geometry-heavy games (orbits, true circles, precise angles), use aspectRatio: { x: 1, y: 1 }.

Making things change

Changing a node's properties

Set fields on yourNode.node, then notify. One onStateChange() at the end covers any number of changes:

this.ball.node.coordinates2d = ShapeUtils.rectangle(newX, newY, 5, 5);
this.ball.node.fill = COLORS.GOLD;
this.scoreText.node.text = { text: 'Score: 3', x: 50, y: 10,
                             size: 4, align: 'center', color: COLORS.WHITE };
this.base.node.onStateChange();   // one call, after all the changes

Adding and removing nodes

Tree operations notify automatically — no onStateChange() needed after these:

this.base.addChild(node);            // put a node on screen
this.base.addChildren(a, b, c);      // several at once
this.base.removeChild(node.id);      // take one off (by id!)
this.base.clearChildren();           // remove everything under this node

Cost: every notification re-sends the entire scene to every player (changes in the same moment get bundled into one send). Notify once per batch or per tick, only when something changed, and keep the node count in the low hundreds.

Players & multiplayer

Player-handling code is the same for single-player and multiplayer games. What differs is one metadata declaration:

// Single-player: no services. Plays in-browser, downloadable for offline play.
static metadata() {
    return { squishVersion: '142', name: 'My Game', aspectRatio: { x: 16, y: 9 } };
}

// Multiplayer: Play runs a browser host and lists a public RTC session.
// Private hides it from discovery; Share shows its QR invitation.
static metadata() {
    return { squishVersion: '142', name: 'My Party Game',
             aspectRatio: { x: 16, y: 9 }, services: ['multiplayer'] };
}

Play starts with one player, so design a useful waiting state or solo mode while others join. Players join the same browser-hosted game over RTC. Do not hardcode player IDs. Players arrive and leave through two methods:

handleNewPlayer({ playerId, info }) {
    // playerId is a number. info.name is their display name (may be absent).
    // Typical move: create their avatar and remember it.
    const avatar = new GameNode.Shape({
        shapeType: Shapes.POLYGON,
        coordinates2d: ShapeUtils.rectangle(47, 47, 6, 6),
        fill: Colors.randomColor(['ALMOST_BLACK'])   // exclude your bg color BY NAME
    });
    this.players[playerId] = { avatar, x: 47, y: 47 };
    this.base.addChild(avatar);
}

handlePlayerDisconnect(playerId) {
    // Clean up their stuff or it stays on screen forever.
    const p = this.players[playerId];
    if (p) {
        this.base.removeChild(p.avatar.id);
        delete this.players[playerId];
    }
}

Private things: playerIds

Every node has an optional playerIds array controlling who can see it. This is how one shared game shows different things to different people — secret cards, personal HUDs, "YOUR TURN" banners:

ValueWho sees the node
omitted or []Shared visibility; an empty array does not hide a node
[42]Selected for player 42's scoped view
[42, 99]Selected for players 42 and 99
[0]Not a safe hide switch. The node remains in the full scene; remove it from the tree to stop rendering it.

playerIds means visibility, not ownership. Tagging each player's ship with playerIds: [playerId] "because it's theirs" makes every ship invisible to every other player. Ships, bullets, and enemies belong to the shared world — leave playerIds off and scope only genuinely private things. Input handlers already receive the acting player's id.

Visibility is not a secrecy guarantee. The runtime can fall back to the full scene for players without a scoped frame, and spectator views can receive the full scene. Give every active player a scoped node as soon as they join, test the second-player view, and keep unrevealed information in game state rather than hidden scene nodes. This does not by itself protect secrets from spectators. Input handlers must independently check player permissions and game rules.

Initialize this.players = {} before using the player example. Player IDs are session identities, not account IDs; do not assume someone reconnecting retains their seat.

Input: taps, buttons, keyboards

Taps and clicks

Put onClick: (playerId, x, y) => { ... } on any Shape or Asset. It fires for mouse presses and taps alike, and can repeat while held. For a one-shot action, latch the press and reset it in handleMouseUp(playerId), as in the first game. Make tap targets generous enough to use on phones.

Taps land on the topmost node and stop there. If a decorative shape covers your button, the tap hits the decoration and is dropped — it doesn't fall through. Two habits prevent this: give invisible grouping containers zero size (rectangle(0,0,0,0)), and add tappable things to the tree after the scenery they sit on.

Keyboard

handleKeyDown(playerId, key) {
    // key is 'ArrowUp', 'w', ' ', 'Enter', ... Support arrows AND wasd.
    const p = this.players[playerId];
    if (!p) return;
    if (key === 'ArrowLeft' || key === 'a') p.vx = -1;
    if (key === 'ArrowRight' || key === 'd') p.vx = 1;
}

handleKeyUp(playerId, key) {
    const p = this.players[playerId];
    if (!p) return;
    if (key === 'ArrowLeft' || key === 'a' || key === 'ArrowRight' || key === 'd') p.vx = 0;
}

While a key is held, the runtime keeps re-sending keydown. That's what you want for movement (set a velocity on down, clear it on up, like above) but wrong for typing — one keystroke arrives as "hhhh". For typed input, see the live-typing section of the full reference.

Phones don't have keyboards. If keys are your only controls, phone players can't play. Either design tap-first, or add on-screen buttons alongside the key handlers.

Dragging and controllers

handleMouseMove(playerId, { x, y }) receives movement while the pointer or finger is held, and handleMouseUp(playerId, data) ends the press. This is not a general hover stream. Node-level onDrag/offClick are newer Squish features; the 142 examples use game-level handlers.

In builds with Homepad integration, map normalized controller controls to your existing key handlers with gamepadBindings in metadata:

gamepadBindings: {
    ArrowLeft: ['DIRECTION_LEFT', 'STICK_1_LEFT'],
    ArrowRight: ['DIRECTION_RIGHT', 'STICK_1_RIGHT'],
    ' ': 'FACE_1'
}

FACE_1 is the bottom face button, independent of the printed letter. Held bindings repeat keydown. For analog input, implement handleGamepadInput(playerId, message) and read normalized message.input, pressed, released, and deltaTime. A controller does not automatically create another player; player assignment belongs to the session. Test the target browser and keep keyboard or touch alternatives available.

Text fields

For entering a name or a guess, give a Shape an input — the player gets a text prompt, and you get the result:

input: {
    type: 'text',
    oninput: (playerId, value) => {
        this.names[playerId] = value;
        // ...update a label, then onStateChange()
    }
}

Movement & game loops

For anything that moves on its own (enemies, gravity, timers), add tickRate to your metadata and implement tick():

static metadata() {
    return {
        squishVersion: '142',
        name: 'My Action Game',
        aspectRatio: { x: 16, y: 9 },
        tickRate: 20        // tick() runs 20 times per second
    };
}

tick() {
    if (this.phase !== 'playing') return;   // see the warning below!

    let changed = false;
    for (const id in this.players) {
        const p = this.players[id];
        if (p.vx === 0 && p.vy === 0) continue;
        p.x = Math.max(0, Math.min(94, p.x + p.vx));
        p.y = Math.max(0, Math.min(94, p.y + p.vy));
        p.avatar.node.coordinates2d = ShapeUtils.rectangle(p.x, p.y, 6, 6);
        changed = true;
    }
    if (changed) this.base.node.onStateChange();   // ONE notify, only if needed
}

Pick the lowest tickRate that feels good: 15–30 for action games, 8–15 for casual ones. For turn-based games, don't write a tick() method at all (just update inside your click handlers) — merely defining tick() starts the loop, at a default 60 per second if you forgot tickRate. Ticks do not automatically notify scene changes; call onStateChange() when the visuals changed. Hosted scene updates are full frames, so notification frequency and scene size affect bandwidth.

tick() starts the moment your game is created — before anyone joins, before anyone presses Start, and it keeps running on menus and game-over screens. Two rules keep you safe:

1. Track a phase (this.phase = 'lobby' / 'playing' / 'over') and bail out of tick() when you're not playing — otherwise enemies spawn behind your start screen.

2. Never let tick() touch a node you haven't created yet. If tick() references this.scoreText but you only create it when the game starts, your game crashes while sitting on the menu. Create everything tick() uses in the constructor.

Timers and cooldowns

In a ticking game, count ticks: this.ticks++ each tick(), and "explode in 3 seconds" becomes this.explodeAt = this.ticks + 3 * TICK_RATE. For event-driven games, use the built-in tracked timers — they clean themselves up when the session ends:

this.setTimeout(() => this.reveal(), 5000);
this.setInterval(() => this.spawnEnemy(), 1000);

Collision

Most games compare rectangles:

const overlaps = (a, b) =>
    a.x < b.x + b.w && a.x + a.w > b.x &&
    a.y < b.y + b.h && a.y + a.h > b.y;

Recipes: "how do I make…"

…a button?

In Squish 142, use a tappable Shape with a Text label on top for a button. Make it a helper and reuse it everywhere:

makeButton({ x, y, w, h, label, fill, onClick }) {
    const bg = new GameNode.Shape({
        shapeType: Shapes.POLYGON,
        coordinates2d: ShapeUtils.rectangle(x, y, w, h),
        fill,
        onClick                       // the shape is the tap target
    });
    bg.addChild(new GameNode.Text({
        textInfo: { text: label, x: x + w / 2, y: y + h / 2 - 1.5,
                    size: 2, align: 'center', color: COLORS.WHITE }
    }));
    return bg;
}

…a start screen?

Keep one this.phase variable and gate everything on it. Start in 'lobby', build the title and a Start button; when it's pressed, remove the lobby nodes, build the play field, and flip the phase. tick() checks the phase first thing.

constructor() {
    super();
    this.phase = 'lobby';
    this.players = {};
    this.base = /* full-screen background */;
    this.lobby = new GameNode.Shape({ shapeType: Shapes.POLYGON,
        coordinates2d: ShapeUtils.rectangle(0, 0, 0, 0) });   // zero-size container
    this.lobby.addChild(this.makeButton({ x: 35, y: 55, w: 30, h: 14,
        label: 'START', fill: COLORS.GREEN,
        onClick: () => this.startRound() }));
    this.base.addChild(this.lobby);
}

startRound() {
    if (this.phase === 'playing') return;
    this.lobby.clearChildren();
    this.phase = 'playing';
    // reset scores/positions, show the play field...
}

…a "play again" button?

Reset your own state: positions and scores back to start, clear the game-over nodes, flip the phase back to playing. Don't use location.reload() — your game has no page to reload, and that line crashes hosted sessions. If setup lives in one startRound() method, play-again is just calling it again.

…enemies that chase players?

// each enemy: { x, y, speed, node }
for (const e of this.enemies) {
    let target = null, best = Infinity;
    for (const id in this.players) {
        const p = this.players[id];
        const d = Math.hypot(p.x - e.x, p.y - e.y);
        if (d < best) { best = d; target = p; }
    }
    if (target) {
        const a = Math.atan2(target.y - e.y, target.x - e.x);
        e.x += Math.cos(a) * e.speed;
        e.y += Math.sin(a) * e.speed;
        e.node.node.coordinates2d = ShapeUtils.rectangle(e.x, e.y, 3, 3);
    }
}

Spawn enemies at the screen edge, not beyond it — coordinates below 0 get pinned to 0 on the wire, so an enemy "hiding" at y = -5 is actually sitting visibly on the top edge. The safe pattern everywhere: keep the logical position in a plain variable and only draw the node once it's fully on screen.

…explosions, particles, trails?

Don't create shapes when something explodes and delete them as they fade — creating and destroying nodes every frame adds allocation and scene-update work. Make a fixed pool once and recycle it:

// Constructor: 40 permanently-allocated particles, hidden (zero size).
this.particles = [];
for (let i = 0; i < 40; i++) {
    const node = new GameNode.Shape({
        shapeType: Shapes.POLYGON,
        coordinates2d: ShapeUtils.rectangle(0, 0, 0, 0),
        fill: [0, 0, 0, 0],
        color: [255, 255, 255, 255]
    });
    this.particles.push({ active: false, x: 0, y: 0, vx: 0, vy: 0, life: 0, node });
    this.base.addChild(node);
}

// Explosion: claim idle slots. If the pool runs dry, emit fewer — that's the budget.
boom(x, y, fill) {
    let n = 12;
    for (const p of this.particles) {
        if (!n) break;
        if (p.active) continue;
        n--;
        p.active = true; p.life = 20; p.x = x; p.y = y;
        const a = Math.random() * Math.PI * 2;
        p.vx = Math.cos(a) * 0.5; p.vy = Math.sin(a) * 0.5;
        p.node.node.fill = fill;
    }
}

// tick(): move the live ones; dead ones go back to zero-size, never removed.
for (const p of this.particles) {
    if (!p.active) continue;
    p.x += p.vx; p.y += p.vy; p.life--;
    if (p.life <= 0) {
        p.active = false;
        p.node.node.coordinates2d = ShapeUtils.rectangle(0, 0, 0, 0);
        p.node.node.fill = [0, 0, 0, 0];
        continue;
    }
    p.node.node.coordinates2d = ShapeUtils.rectangle(p.x, p.y, 0.7, 0.7);
    p.node.node.color = [255, 255, 255, Math.round(255 * p.life / 20)];  // fade out
}
this.base.node.onStateChange();

Note the fade uses the color field's alpha, not fill's — fill alpha is effectively on/off (anything above 0 renders solid), while color alpha actually fades the node.

…a winner?

If the rule is "last player alive wins," special-case solo play — with one player in the session, they're the last one alive the moment the game starts. Require two participants for last-one-standing and give solo players a survival timer or score target instead.

…a secret hand of cards?

Use playerIds: [playerId] for personal hand views, and initialize a scoped node for every player. Read the visibility limitations: full-scene fallback and spectators mean this is not enough to guarantee a secret hand. Keep the deck and unrevealed cards out of the render tree, and test every supported viewing mode.

Images & sound

Images and audio live in the Homegames asset store, and your game references them by id. Declare them in metadata, then place them with an Asset node:

const { Asset } = require('squish-142');

// in metadata():
assets: {
    'hero': new Asset({ id: 'your-asset-id-here', type: 'image' }),
    'ding': new Asset({ id: 'another-asset-id',   type: 'audio' })
}

// an image on screen:
const hero = new GameNode.Asset({
    coordinates2d: ShapeUtils.rectangle(30, 30, 20, 20),   // its tappable bounds
    assetInfo: { 'hero': { pos: { x: 30, y: 30 }, size: { x: 20, y: 20 } } }
});
this.base.addChild(hero);

// a sound (zero size; plays while in the tree):
const ding = new GameNode.Asset({
    coordinates2d: ShapeUtils.rectangle(0, 0, 0, 0),
    assetInfo: { 'ding': { pos: { x: 0, y: 0 }, size: { x: 0, y: 0 }, startTime: 0 } }
});
this.base.addChild(ding);
this.setTimeout(() => this.base.removeChild(ding.id), 500);

Upload images and sounds with the Assets button in the Studio; it gives you IDs to paste into your metadata. Keep asset keys short (at most 32 ASCII characters for the bundle format), stable, and identical in metadata and assetInfo. A missing asset may fail the entire preview or game load; use shapes and text until you have real IDs.

Studio can also draw images, record audio, and browse the public asset catalog. Uploading or saving an asset to the service requires email verification. Publish to catalog makes an asset discoverable; turning it off does not prevent downloading that asset by ID. Removing an asset that a game references can break future loads of that game.

Declared assets must be recognizable in index.js for local previews and downloadable games. Use literal new Asset({ id: '...', type: '...' }) declarations rather than constructing them in helper functions. Asset nodes use the metadata key, not the asset ID. Audio normally needs a player gesture before the browser will play it. Removing an audio node stops playback; the 500 ms timeout above deliberately cuts off a longer clip.

Saving game progress

Saving your source with Studio's Save Version does not save a player's progress. A game opts into progress saving through constructor options:

constructor({ saveGame, saveData } = {}) {
    super();
    this.saveGame = saveGame;
    this.best = Number.isFinite(saveData?.best) ? saveData.best : 0;
    // Build the scene here, including any labels that show this.best.
}

async recordBest(score) {
    if (!Number.isFinite(score) || score <= this.best) return;
    this.best = score;
    if (this.saveGame) {
        try { await this.saveGame({ best: this.best }); }
        catch (err) { console.error('Could not save progress:', err); }
    }
}

Pass small JSON-serializable data, not nodes, functions, or circular objects. Keep it below 1 MiB; writes are debounced and rapid calls keep the latest value. saveData is supplied when the game is constructed and can be absent. Validate it before use.

ModeWhere saves go
Browser-local playBrowser storage, normally localStorage. If storage is unavailable, persistence cannot be relied on. Website play and downloads use the catalog game ID; Studio previews use a separate game/template identity. There is one local slot per game identity, with the version recorded in the save. Saves are not synchronized with an account or stored in a separate browser slot for each published commit.
Hosted game with disk savesThe host's per-game, per-version save directory. This is shared game data, not automatically a separate save slot for every player or session. Concurrent sessions for the same game/version can overwrite that save.
Hosted game with disk saves disabledSave envelopes are sent to connected browsers. Restoring requires the session creator's launch flow to provide a stored envelope. The home dashboard has this handoff; the website's ordinary session-create request currently does not send one.

For versioned host saves, the runtime first tries the current version, then entries of metadata().saveCompatibility in order. This is permission to read an older format, not an automatic migration. Browser-local play checks its one stored envelope against the selected version and that same compatibility list. Saving a new version can replace the older envelope. Unversioned work uses local-game-version. Older class-name-keyed browser saves are retained but are not automatically moved to a catalog or Studio identity, because several games can share a class name. A downloaded game file includes code and assets, not existing progress.

Gotchas

Common problems and their usual causes.

Test and share

Start with Studio's local preview, which runs the current editor files without publishing. Use + Player to add a second simulated view of the same instance. This helps catch invisible avatars, incorrect turn handling, and per-player UI mistakes.

The local Inspect control exposes the scene tree and recorded frames. Use it to find a node, inspect its properties, or see which object covers a button. Stopping with the inspector active preserves the recorded view; restarting runs a new game.

Use Share to invite friends to the current preview over RTC. Studio asks you to sign in for this option. Sharing does not restart the game or upload its source to Core. Keep the preview tab open; stopping or restarting it ends the invitation.

Shared browser games end when the host closes or restarts the tab. The API removes stale listings when heartbeats stop. There is no session migration or pinning. See sharing a session.

Publish to the catalog

Save and test your intended version, then use Publish beside its commit in Studio's version history. The following checks apply:

  1. Account and listing: verify your email, set the game's description and thumbnail in Studio settings, and change the starter template.
  2. Repository: include root index.js, the full GPLv3 license text (Studio creates LICENSE), and a README explaining the game and controls. The worker requires at least 40 characters of README content beyond headings.
  3. Metadata and source: use a literal name and supported Squish version. Only literal imports of your own relative files and supplied Squish aliases are accepted. Filesystem/network built-ins, arbitrary npm modules, dynamic imports/requires, eval, and code-generation constructs fail validation.
  4. Size: keep each JavaScript source file at or below 5 MiB and the checked repository contents at or below 20 MiB. Symbolic links are rejected. Store media through the asset tools rather than embedding it in source.
  5. Runtime: Docker validation must be available. It checks metadata and scene serialization, simulates player input and lifecycle events, and runs the game briefly with networking disabled. A local preview pass is not a validation pass.

Status progresses from PENDING to PROCESSING, then PUBLISHED or FAILED. A failed request includes an error; fix the underlying issue, save a new version, and submit that commit. Publishing is limited to one request per 10 minutes per user, including requests for other games.

FailureWhat to do
Email verification requiredEnter the emailed code in Studio, or resend it from the verification banner, then retry.
Missing description / thumbnailUpdate the game's Studio settings; adding only metadata fields to the source does not set these listing fields.
README too thin / license mismatchDescribe the rules and controls in ordinary prose and preserve the complete GPLv3 license text.
Source or runtime validation failedRead the file/line or runtime error, test the affected code, and check the Squish version and required assets.
Docker validation unavailable / request stays pendingThis can be a service-side issue. Keep the request ID and commit SHA for support; do not repeatedly create duplicate submissions.

A passing version becomes publicly listed without a separate manual featuring step. Administrators choose featured games separately. Published source is browsable through View Source. Eligible games get local Play and Download; games declaring services: ['multiplayer'] also get hosted multiplayer controls. Downloaded HTML games run as solo sessions and include the selected version's declared assets.

Saving or restoring source does not replace a published commit. Publishing another version adds a version; existing sessions and downloaded files continue running their earlier code.

Going further

Something wrong or confusing on this page? Email joseph@homegames.io or open an issue on GitHub.