On this page
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:
- Open Studio (no account needed).
- Open Games, choose New game and pick a template, or use Import game.
- Hit Play.
- 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
- 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.
- Try one change. Play runs your current source. After editing, use Play again to restart the preview. Playing does not save your source.
- 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.
- 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.
- 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 field | What 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: 20 | Target ticks per second if you implement
tick(); use 1–120. Omitting it defaults the loop to 60. |
maxPlayers: 4 | Required positive integer, including the host. Use 1 for solo; values above 1 enable invitations. RTC transport limits can impose a smaller limit. |
saveSchemaVersion: 1 | Defaults 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:
- Build the scene from shapes, text and assets. The client renders those nodes to a canvas.
- The runtime handles networking. Players in a shared session interact with the same game instance.
- Do not depend on
window,document,location, oralert. They are absent in Node.js games. Studio runs portable games inside a sandboxed frame; use the game API and injected save/media helpers rather than accessing editor storage or DOM.
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:
- Delete the
onStateChange()line. Taps still register but the score on screen never changes. - Change
rectangle(40, 40, 20, 20)torectangle(10, 70, 40, 15)to move and resize the square. - Change
COLORS.CANDY_REDto[0, 200, 100, 255]. Colors are[red, green, blue, alpha], each 0–255.
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:
| Value | Who 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.
| Mode | Where saves go |
|---|---|
| Website games / Studio / exported HTML | A 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 paths | The older browser envelope uses catalog identity and a version compatibility check. Its storage and debounce behavior differ from portable saves. |
| Node/WebSocket with disk saves | The host's per-game/per-version save directory. Concurrent sessions of that game/version can share the same slot. |
| Node/WebSocket with disk saves disabled | Envelopes 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.
- Screen not updating: missing
this.base.node.onStateChange()after changing node properties. - Button not working: the
onClickis on a Text node (put it on a Shape), or something invisible is drawn over it and swallowing the tap. - Crash on the menu:
tick()is touching a node that doesn't exist yet, or the code references a browser global (location,window,alert). - Other players can't see something:
playerIdsis set on a shared object. Scope by who should see it, including camera-specific world views. - Game slows down over time: nodes created every tick and not all removed. Pool and reuse instead.
- Fade looks broken: fade with
coloralpha, notfillalpha, and clamp computed channels to 0–255 (they wrap). - Multiline text renders on one line:
\ndoesn't line-break in Text nodes. Use one Text node per line. - Shape doesn't appear: often a made-up color name.
COLORS.CHOCOLATEisn't an error, it'sundefined, and the shape renders with no fill. Use a real palette name or an explicit[r,g,b,a]. - Circles are ovals: the grid stretches to your aspect ratio. Use
{ x: 1, y: 1 }for geometric games. - "Off-screen" spawn visible at the edge: negative coordinates pin to 0. Spawn at the edge moving inward, or off the right/bottom.
- Client renders slowly: measure canvas work, especially shadows, text and large images. Node count alone does not explain rendering cost.
- Single player instantly wins: a last-one-standing check firing with one person in the session.
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.
- Test alone, with a real second player, and after a player leaves and returns.
- Test quick taps, held presses, keyboard repeats, touch and controller bindings.
- Test the lobby, game over, play again, and long runs for accumulating nodes/timers.
- Test on a phone; preview success does not establish every browser or network works.
- If the game saves, reload, restore a compatible JSON backup, and check failures visibly.
- Import the exported ZIP into a new installation and open the HTML while offline.
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.
| Problem | What to check |
|---|---|
| Runtime mismatch | Use 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 module | Return one literal object; import only the selected Squish alias and relative JavaScript modules. |
| Missing/invalid media | Use real project files and matching media types, declare them in metadata, and replace hosted asset IDs with paths. |
| Archive too large / invalid inventory | Reduce files/media or obtain a new Export ZIP. Import expects the generated inventory, not an arbitrary source archive. |
| Not saved / conflict | Keep 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
- Extended technical reference — additional examples for scrolling worlds and per-player cameras (
ViewableGame), spritesheets, live typing, and bot AI. It also covers Node-hosted games; check which runtime a feature needs. Studio’s Learn → Game API guide browses it by topic and copies the full reference for a coding assistant. - Snippets — Learn → Code snippets has searchable building blocks for input, movement, collisions, rounds, assets, multiplayer and saved progress, with placement instructions for each block.
- Read real games — every first-party game is open source in the homegamesio GitHub org.
- How the platform works — the end-to-end system view (sessions, Squish, game packs, homegames.link) is at how it works.
- Self-hosting — run a dashboard and game sessions on your own computer; see self-hosting for how that differs from operating the public services.
Something wrong or confusing on this page? Email joseph@homegames.io or open an issue on GitHub.