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 an index.js. No framework,
no build step. Bigger games can split into multiple files with relative requires.
The platform runs your class and renders what it describes: single-player games run in the
player's browser, multiplayer games run in a hosted session that players connect to. Input
comes back to your code the same way in both.
To try it:
- Open the Studio (no account needed).
- Pick a template.
- Hit Play.
- Change something and hit Play again.
The rest of this page explains what the code means.
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. Single-player games run in the player's browser (this is also how downloaded games work offline). Multiplayer games run on a hosted server that players' browsers connect to. Your game can't tell the difference and shouldn't try. In both cases:
- 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.
- Your code gets no browser objects — no
window,document,location, oralert. Hosted sessions run in Node.js where those don't exist, and the local runtime doesn't expose them either. See the gotchas.
2. The screen is a 100×100 grid
Every position and size is a number from 0 to 100. (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, which is how card games keep hands secret in
a shared game.
Your first game, line by line
A complete working game: a square you tap 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;
// 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 square. onClick fires when any player taps it.
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. Almost always exactly this.
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 (only used with border)
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! */ }
});
There are no circles. A Shapes.CIRCLE constant exists, but it does not render. 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
}
});
Text can't be tapped. onClick on a Text node is silently ignored. 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
(RED, GOLD, EMERALD, HG_BLUE,
CANDY_PINK, ~100 more), and Colors.randomColor().
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: declare the service. The game's page gets "Play with friends",
// which starts a hosted session with a shareable join link.
static metadata() {
return { squishVersion: '142', name: 'My Party Game',
aspectRatio: { x: 16, y: 9 }, services: ['multiplayer'] };
}
Multiplayer games are also playable solo — the catalog offers them as "Play offline", running
as a one-player local session — so they should still work with one player (bots filling empty
seats is the usual fix). In a local session the player's id is 1; hosted sessions
assign arbitrary ids, so don't hardcode it. 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 | Everyone (this is the default — use it for almost everything) |
[42] | Only player 42 |
[42, 99] | Players 42 and 99 |
[0] | Nobody — the standard trick for temporarily hiding a node |
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.
Input: taps, buttons, keyboards
Taps and clicks
Put onClick: (playerId, x, y) => { ... } on any Shape or Asset. It fires for
mouse clicks and taps alike. Most players are on phones, so make tap targets big — a 30×12
button, not an 8×3 one.
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.
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, none at all for turn-based games (just update inside your click handlers). Every ticking
frame costs real bandwidth for every player.
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?
There's no button node, and Text can't be tapped — so a button is a tappable Shape with a Text label on top. 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.node.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. Spawning off the right/bottom (up to 255) does work.
…explosions, particles, trails?
Don't create shapes when something explodes and delete them as they fade — creating and destroying nodes every frame is the main cause of slow games. 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
}
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?
Scope each player's hand nodes with playerIds: [playerId], and give every player at
least one scoped node the moment they join (a zero-size invisible one works) — see the privacy
notes in the full reference for why that matters.
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. A made-up asset id renders nothing — until you have real assets, draw with shapes and text.
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 (ignored — 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 & share
The Studio's Play button runs your game two ways:
- Local preview — runs instantly in your own tab, no account needed. This is the same in-browser runtime players get when they click Play on a published game.
- Online session — hosts a real session with a shareable link so friends can join from their phones. Requires a free account.
Test on a phone early — that's how most people play. It shows you quickly whether your buttons are big enough and whether the game secretly requires a keyboard.
Save Version snapshots your code; any earlier version can be restored.
Publish to the catalog
Publishing happens from the Studio:
- Hit Publish on a saved version (verified account required).
- Automated checks run the game briefly in a sandbox. It has to load, run, and not crash, and it can only use the squish library and its own files — no network calls, no filesystem, no
eval. - Once it passes, the game is publicly listed in the catalog. Featured games are hand-picked by a human — there's no review step just to get listed.
A published game gets a page on homegames.io with:
- Play — single-player games run in the visitor's browser when they click it.
- Play with friends — for games that declare
services: ['multiplayer']. Starts a hosted session and gives the creator a link; anyone who opens the link joins. - Download — a single HTML file containing the game, the runtime, and its assets. Runs offline. Multiplayer features don't work in a downloaded copy; it runs as a solo local session.
- View Source — the published code is browsable from the page.
Note: the platform decides these play modes by reading your metadata() without
running your code, so keep it a plain literal — name: 'My Game', not
name: computeName().
Published games are open source under GPLv3, like every game on the platform.
Going further
- Full technical reference — the complete contract: every node option, scrolling worlds and per-player cameras (
ViewableGame), spritesheets, live typing, bot AI. It's also written to work as LLM context — the Studio's Guide button copies it for pasting into 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 — the whole platform can run on your own machine; see self-hosting.
Something wrong or confusing on this page? Email joseph@homegames.io or open an issue on GitHub.