On this page
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.
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:
- Open the Studio (no account needed).
- Pick a template.
- Hit Play.
- 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
- 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.
- 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.
- 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.
- 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.
- 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 field | What 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: 20 | Target 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:
- You never draw anything. No canvas, no CSS, no HTML. You describe what exists (a red square here, some text there) and the platform renders it.
- You never write networking. If four people join a hosted session, they're all in your one game instance.
- Do not depend on
window,document,location, oralert. They are absent in hosted Node.js games. The local loader executes in a browser context, so previewing successfully does not prove your code is portable or sandboxed.
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:
- 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: { // 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:
| Value | Who 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.
| Mode | Where saves go |
|---|---|
| Browser-local play | Browser 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 saves | The 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 disabled | Save 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.
- Screen not updating: missing
this.base.node.onStateChange()after changing node properties. - Button not working: in Squish 142, 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. It's for private UI only. - 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: too many glowing nodes. The glow effect is expensive — use it on a few focal objects.
- Single player instantly wins: a last-one-standing check firing with one person in the session.
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.
- Test with one player, then two, then a player leaving and returning.
- Test held presses as well as quick taps, keyboard repeats, touch, and any controller bindings.
- Test the lobby, game over, play again, and a long enough run to catch accumulating nodes or timers.
- Test on a phone and in a hosted session, even if local preview succeeds.
- If the game saves, reload and verify restored data, including an older save format.
- After publishing, test the selected version's download without internet access.
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:
- Account and listing: verify your email, set the game's description and thumbnail in Studio settings, and change the starter template.
- Repository: include root
index.js, the full GPLv3 license text (Studio createsLICENSE), and a README explaining the game and controls. The worker requires at least 40 characters of README content beyond headings. - 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. - 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.
- 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.
| Failure | What to do |
|---|---|
| Email verification required | Enter the emailed code in Studio, or resend it from the verification banner, then retry. |
| Missing description / thumbnail | Update the game's Studio settings; adding only metadata fields to the source does not set these listing fields. |
| README too thin / license mismatch | Describe the rules and controls in ordinary prose and preserve the complete GPLv3 license text. |
| Source or runtime validation failed | Read the file/line or runtime error, test the affected code, and check the Squish version and required assets. |
| Docker validation unavailable / request stays pending | This 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
- Extended technical reference — additional examples for scrolling worlds and per-player cameras (
ViewableGame), spritesheets, live typing, and bot AI. This separate shared document can describe older behavior or different Squish versions; check examples against your runtime. Studio's Guide button copies it for use with an AI assistant. - Snippets — the Snippets button in the Studio has copy-paste blocks for buttons, sounds, glow, per-player visibility, and more.
- 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, publishing, 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.