On this page
Start
Start here Studio workflow Files & metadata Game structure 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 Export and share Going further

How to make Homegames games

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

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 locally. A browser host can invite other players into the same game.

To try it:

  1. Open Studio (no account needed).
  2. Open Games, choose New game and pick a template, or use Import game.
  3. Hit Play.
  4. Change something, hit Play again, then choose Save to keep it.

This tutorial uses squish-151. The runtime version comes from that import; do not repeat it in metadata. Every Squish import in the project must use the same supported alias. The rest of this page explains the code and how to ship it.

A reliable Studio workflow

  1. Choose a game. Games opens the switcher. Create a game from a template, import an HTML/ZIP file, or open an existing project. The editor is your main workspace.
  2. Try one change. Play runs your current source. After editing, use Play again to restart the preview. Playing does not save your source.
  3. Save deliberately. Save (⌘S / Ctrl+S) keeps your source and media in this browser and adds a snapshot to History. Add an optional save message to name it. Unfinished code can be saved too. Edits stay unsaved until you choose Save.
  4. Look back. History sits to the left of Files. Choose a snapshot to read, play or export it. Back to editing returns to your working copy. Restore loads that snapshot into the editor; Save keeps the restored version. Unsaved changes require confirmation.
  5. Share or keep a copy. Export & share offers a playable HTML file, a source ZIP, or Share an editable copy, which sends the game straight to another person. To play together, open the Homegames menu in the preview. The host tab must stay open for guests.

Settings holds light/dark appearance, full Studio backups, and browser storage. Download backup includes your games, unfinished working copy, history, assets, play progress and preferences in one file. Restore backup replaces the current Studio with an option to undo. Use this to move your whole workspace to another browser or device.

Games are stored in this browser on this website address. Clearing browser data can remove them; keep a downloaded backup. Manage storage shows usage, per-game sizes, and export/delete controls. A competing tab's save produces a conflict with copy/reload recovery.

If unfinished code prevents a playable export, Export ZIP downloads a .studio.zip draft. Import it through Games → Import game to continue editing. Individual ZIP/HTML exports contain the selected source and assets; full history and existing progress stay in Studio backups.

Under Learn, Interactive examples lets you play with a working game and open a copy; Code snippets supplies blocks with exact insertion points; Game API guide is a searchable reference. You can use these while keeping your current edits.

Files and metadata

New projects use index.js as their entry. The exported game class owns the authored metadata; no separate homegame.json is needed. New game assigns a stable UUID. Keep it while editing, saving and exporting. To start an independent game, use New game, or Edit this game on a website game page, which gives the copy its own identity. Names can change without changing identity or saved progress.

static metadata() {
    return {
        gameId: '917a819d-148f-4495-a242-47b5cad62898',
        name: 'My Game',
        maxPlayers: 1,
        aspectRatio: { x: 16, y: 9 },
        saveSchemaVersion: 1
    };
}

The UUID above is illustrative; keep the unique value provided by your template. Export ZIP generates an integrity inventory; Import game verifies it. A plain ZIP of arbitrary source files is not a validated portable project. Older exported games with a homegame.json remain supported, including custom entry paths. Importing or exporting them preserves their identity and format; do not delete their manifest by hand.

Export your game class with module.exports = MyGame. Import the supplied Squish alias and your own relative JavaScript modules; arbitrary npm/Node modules and remote imports are unsupported.

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

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

Put runtime data in JavaScript exports and media in project files. Relative requires resolve from the importing module; asset paths resolve from the project root.

Metadata fieldWhat it means
gameId: '…'Required stable UUID. Keep it for revisions and ordinary copies; create a new one only for a separate game.
name: 'My Game'The game's display name. Studio's library name is separate.
aspectRatio: { x: 16, y: 9 }Viewport proportions. Coordinates still run from 0 to 100 on each axis.
tickRate: 20Target ticks per second if you implement tick(); use 1–120. Omitting it defaults the loop to 60.
maxPlayers: 4Required positive integer, including the host. Use 1 for solo; values above 1 enable invitations. RTC transport limits can impose a smaller limit.
saveSchemaVersion: 1Defaults to 1. Increase when the shape of your saved data changes incompatibly; changing a game's name does not require a new schema.
assets: { ... }Literal keys mapped to new Asset({ path: './assets/hero.png', type: 'image' }); see assets.
saveCompatibility: ['save-schema:1']Explicitly allow an older portable save schema that your code can read.

Portable metadata() must contain a single return of a literal object. All values, including tickRate and maxPlayers, must be literal; do not reference constants, call helpers or spread other objects there. Asset declarations use the literal form above. Source is inspected without executing it. Do not declare services or repeat squishVersion; the runtime comes from require('squish-151'). Current runtime packages support Squish 1.5.1 (squish-151) and existing 1.5.0 projects (squish-150). Older downloads retain their bundled runtime.

Game structure

The runtime manages the game instance, rendering and player connections. See how Homegames works for the platform overview.

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 and shared website play run that instance in the host browser. Self-hosted games run on your computer. The same lifecycle and scene APIs work in both; portable games use bundled files and the documented runtime APIs. 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

The basic node types are: 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. Notify scene changes

The platform doesn't watch your variables. After changing node properties, call this.base.node.onStateChange() once to push the new state to players. Without a notification, node-property changes do not reach the renderer.

5. Player IDs identify input and views

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 rules 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-151');
const { COLORS } = Colors;

class TapGame extends Game {

    // Keep the UUID assigned by New game when editing this example.
    static metadata() {
        return {
            gameId: 'ac308454-5a27-4748-9a46-ce8f3601739d',
            maxPlayers: 1,
            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;

        // 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. onClick fires once per press.
        this.target = new GameNode.Shape({
            shapeType: Shapes.POLYGON,
            coordinates2d: ShapeUtils.rectangle(40, 40, 20, 20),
            fill: COLORS.CANDY_RED,
            onClick: (playerId, x, y) => {
                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);
    }

    // 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: {                        // optional shadow
        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
    }
});

Put button handlers on Shapes. Taps on a Text node don't reach its 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: a dirty flush serializes the host tree, then builds a filtered view for each recipient. RTC sends full per-player snapshots, skips identical frames and can coalesce queued updates. Notify once per batch or tick when something changed; detach obsolete nodes and measure host work, frame bytes and client paint time separately.

Budget for the slowest player. A host uploads a separate view to each guest; increasing the player count multiplies that work. Start animation at 20–30 ticks per second, send a change only when visible state changed, and measure traffic and frame time with real guests. Avoid an ever-growing tree of dots, effects, chat lines or off-screen scenery. Remove expired nodes, limit particles and history, and use a bounded view for scrolling worlds. Low tick rates help only if other input or timer handlers are not flooding updates.

Players & multiplayer

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

// Solo: no session invitation is offered. Copy sharing still works.
static metadata() {
    return { gameId: 'b419d54d-c746-4b92-bced-eeb8c106a8a8',
             name: 'My Game', maxPlayers: 1, aspectRatio: { x: 16, y: 9 } };
}

// Multiplayer: the browser hosts; a session invitation joins this game.
// maxPlayers counts the host as well as guests.
static metadata() {
    return { gameId: '303a9cb7-9a24-48fc-bc34-d2ac7aef941b',
             name: 'My Party Game', maxPlayers: 4, aspectRatio: { x: 16, y: 9 } };
}

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, rename themselves, and leave through these methods. The player menu’s Your name field starts with a random name and remembers changes in this browser.

handleNewPlayer({ playerId, info }) {
    // playerId is a number. info.name is their current display name.
    // 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, name: info.name, x: 47, y: 47 };
    this.base.addChild(avatar);
}

handlePlayerUpdate(playerId, { info, settings }) {
    // A rename keeps the same playerId. Refresh any labels or roster here.
    const player = this.players[playerId];
    if (player) player.name = info.name;
}

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 []Inherits ancestor visibility; public under a public root
[42]Selected for player 42's scoped view
[42, 99]Selected for players 42 and 99
[0]No live player sees it, but the host still serializes it. Remove unused nodes from the tree to avoid that work.

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. In a shared arena, leave ships and bullets public. For separate cameras, scope each camera subtree to its viewer and include the other players that viewer should see. Input handlers receive the acting player's id.

The shared runtime filters every recipient's frame, including players without scoped nodes and spectators. Children cannot broaden an ancestor's audience, and an empty array does not reset privacy. A special scoped anchor node is unnecessary. Keep unrevealed state out of the render tree and authorize input handlers independently. Asset bundles and live media may be room-wide; node scopes do not make those bytes private.

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, once per press. While held, movement calls onDrag(playerId, x, y); release calls offClick and handleMouseUp. For repeated actions, track held state and advance it in tick(). 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.

Support touch input. Add on-screen controls for phone players when the game uses keyboard input.

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. Nodes can also take their own onDrag and offClick handlers; the Drag and drop example uses onDrag.

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 {
        gameId: '47d0bc86-cfed-4013-8065-4b828fa06fce',
        maxPlayers: 4,
        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?

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. Ancestor scopes also apply, so put shared table cards under a public root. Keep the deck and unrevealed cards out of the render tree, validate each player action, and test every supported viewing mode. See the visibility rules.

Images & sound

Open Assets → My Assets to keep a reusable library on this device. Upload a file, draw an image, record a sound or use the keyboard—even before creating a game. Open your game and click Add assets in the editor. Check the assets you want, then click Add selected to copy them into that game’s assets/ folder. Cancel leaves the game unchanged.

Existing filenames are kept; new copies get a numbered name when needed. Assets → This game shows the open game’s copies and provides code to copy. Use Save to My Assets there to reuse one in another game. Deleting a library original leaves game copies alone; deleting a game leaves its library originals alone. Downloads are available in both views.

Portable images, audio and video live in your project. Declare their project-relative paths in metadata, then place them with an Asset node:

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

// in metadata():
assets: {
    'hero': new Asset({ path: './assets/hero.png', type: 'image' }),
    'ding': new Asset({ path: './assets/ding.wav', 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);

Add real media using Studio’s Assets tools. Upload, drawing, recording and keyboard tools keep originals in your asset library. Select them and choose Add to game to copy files into assets/, then Save your project. Use the supplied path-based code. Keys such as hero must match assetInfo, use at most 32 letters/digits/underscores/hyphens, and remain stable.

Declare authored assets literally in the entry file's metadata. Portable projects reject hosted IDs, dynamic Asset construction and getAssets(); use actual local files with matching image/audio/video types. Paths are relative to the project root, even if game logic is split across modules. Authored assets together must fit the current 10 MiB RTC bundle limit. Player-selected session uploads use a separate 5 MiB limit.

Audio normally needs a user gesture. Removing an audio node stops playback; the 500 ms timeout above deliberately cuts off a longer clip. Hosted asset IDs from the old Homegames catalog aren't supported; put the files in your project instead.

Saving game progress

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

constructor({ saveGame, saveData } = {}) {
    super();
    this.saveProgress = saveGame;
    this.pendingBest = Promise.resolve();
    this.best = Number.isFinite(saveData?.best) && saveData.best >= 0 ? saveData.best : 0;
    // Build the scene, including this.bestLabel (a Text node).
}

recordBest(score) {
    if (!Number.isFinite(score) || score < 0) return this.pendingBest;
    // Queue writes so an older score cannot replace a newer best.
    this.pendingBest = this.pendingBest.then(async () => {
        if (score <= this.best) return;
        try {
            if (!this.saveProgress) throw new Error('Saving unavailable');
            await this.saveProgress({ best: score });
            this.best = score;
            this.bestLabel.node.text.text = 'Best: ' + this.best;
        } catch (error) {
            this.bestLabel.node.text.text = 'Could not save. Try again.';
        }
        this.base.node.onStateChange();
    });
    return this.pendingBest;
}

Call recordBest(score) when a round ends. Keep the prior saved best until the write succeeds so the same score can be retried after a failure. For the complete label setup, use Learn → Code snippets → Save a best score.

Pass at most 1 MiB of UTF-8 JSON, not nodes, functions or circular objects. saveData can be absent and must be validated. Portable saves are queued; saveGame resolves after the IndexedDB transaction commits, without a write debounce. Handle rejected writes and visible save errors.

ModeWhere saves go
Website games / Studio / exported HTMLA separate IndexedDB database in the trusted parent, scoped to this installation and save-schema:N. The game accesses it through injected helpers, not direct editor-storage access.
Older browser launch pathsThe older browser envelope uses catalog identity and a version compatibility check. Its storage and debounce behavior differ from portable saves.
Node/WebSocket with disk savesThe host's per-game/per-version save directory. Concurrent sessions of that game/version can share the same slot.
Node/WebSocket with disk saves disabledEnvelopes are sent to browsers; restoring requires the launch flow to supply a saved envelope.

For portable projects, metadata.saveSchemaVersion (or the manifest field in legacy projects) controls compatibility independently of source snapshots. List older schemas explicitly, for example saveCompatibility: ['save-schema:1'], only if you can read their data.

Restore snapshot changes source/media, not progress. Each ZIP import and new HTML export gets a fresh installation; use Download save and Load save to transfer compatible progress. Exports do not include a current save. Browser storage can be cleared, evicted or quota-blocked; keep separate project and progress backups.

Keep a game as an app

Choose Download app in the game toolbar. When the browser supports an install prompt, the button opens it directly. Otherwise it shows steps for your device: Add to Home Screen on iPhone/iPad, Add to Dock in Safari on Mac, or the browser’s Install app option on Windows. Open the new icon once while online so a catalog game can prepare its offline copy.

Download game file (HTML) remains available in that dialog and under More. For a private or imported game, keep that file and open it inside the new app if needed. An app may have separate browser storage; use Download save/Load save to transfer progress. Downloaded HTML files remain playable in desktop browsers and show Download file in their toolbar.

Gotchas

Common problems and their usual causes.

Treat player input as requests. Check that the player exists and owns the object they are trying to move. Validate ranges, turn order, cooldowns and text length in your game logic. A private visual node hides information but does not replace authorization. Never include secret game state in a public label or assume a hidden button cannot be requested.

Test and share

Play runs the selected portable project in an isolated preview. With maxPlayers above 1, use the Homegames menu to invite players to the same live game from another browser/device; no sign-in is needed.

The host tab must stay open and awake. Restarting the preview ends that game; there is no host migration or pinning. Manual connection can bypass Homegames signaling when both players have a loaded/downloaded player, but some networks still need a relay. See sharing a session.

Export and share

Open Export & share. Download source ZIP produces editable source, authored media and a generated inventory. Legacy games also retain their original manifest. Send this file to someone who wants their own project. Download game HTML produces a playable copy with the runtime and media embedded. Share an editable copy sends the game files straight to another person's browser; The session invitation in the preview brings players into the running multiplayer game.

Play/export checks format, safe paths, runtime agreement, literal metadata, imports, recognizable media and size limits. It doesn't play your game for you or guarantee every branch of your game works. Drafts and snapshots may save successfully while export still reports a validation problem.

ProblemWhat to check
Runtime mismatchUse one supported alias in every Squish import. For legacy v1 projects, keep the manifest runtime and any declared metadata version consistent too.
Non-literal metadata / unsupported moduleReturn one literal object; import only the selected Squish alias and relative JavaScript modules.
Missing/invalid mediaUse real project files and matching media types, declare them in metadata, and replace hosted asset IDs with paths.
Archive too large / invalid inventoryReduce files/media or obtain a new Export ZIP. Import expects the generated inventory, not an arbitrary source archive.
Not saved / conflictKeep unsaved work, save a copy or reload the saved version; check quota and the other open project tabs.

The games on this website are chosen when the site is built. Studio saves your projects in this browser; Download source ZIP makes a portable pack that others can import and edit. Edit this game on a game page saves an editable copy of that game. Your edits and exports don't change the website. A session invitation brings players into the current browser-hosted multiplayer game.

Homegames code and templates use the MIT License. Preserve copyright and license files when sharing them. Imported projects and third-party code/media retain their own terms; exporting does not relicense them. New builds use Squish 1.5.1 (squish-151). Snapshot history and runtime progress are separate from ZIP/HTML exports; back them up deliberately.

Going further

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