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.

Have a question about Homegames or making games? Ask here — answered by a model running on Homegames hardware, so it may take a minute.

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:

  1. Open the Studio (no account needed).
  2. Pick a template.
  3. Hit Play.
  4. 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:

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:

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:

ValueWho sees the node
omittedEveryone (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.

Test & share

The Studio's Play button runs your game two ways:

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:

  1. Hit Publish on a saved version (verified account required).
  2. 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.
  3. 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:

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

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