← All documentation

Making a game feel good

Movement, feedback and sound — the half of a game that is not the rules.

This is the part that decides whether a player comes back, and it is the part most easily got wrong in ways that cost somebody money. Everything here is render-only: delete every call in this document and your game still plays identically and still passes certification. That property is not a nicety. It is the thing that makes any of it safe.


The one rule

Nothing in this document may touch your rules.

Your rules run on the server; everything on this page runs in the player's browser, and can only change what the player sees. Movement, feedback and sound are drawn from the frames the server sends — the board before and after a move — and nothing drawn may run ahead of them. A piece shown where the server did not put it is a board that does not exist.

So:

Allowed Never
Read state and draw it somewhere else Write to state
Count frames Read Date.now or performance.now in rules
Use Math.random for particles only Draw from the game's prng for anything visual
Delay what is drawn Delay what is applied

The third row is the one people get wrong. Read on.


Movement

Your rules move a piece from one cell to another instantly. Nothing in the new state says it travelled, so there is nothing for an animation to follow. Motions recovers it.

import { Motions } from "@crazy8s/game-sdk";

const motions = new Motions(8);   // 8 frames per move

Frames, not milliseconds

Durations are counted in frames, matching the loop that drives your game.

Wall-clock timing would make an animation finish at a different point in the tick sequence on a slow phone than on a fast one — so a piece could still be visibly sliding into a cell the rules had already emptied. Counting frames keeps what the player sees a fixed number of steps behind what the rules did, on every device. It also means an animation cannot outlive the run: the loop stops, the counter stops, everything settles.

follow or move — the distinction that matters

follow(key, where, frame) is for pieces with an identity of their own. A card is the eight of hearts wherever it sits, so pass where it belongs now and the first frame that differs starts a slide from the old place. No diffing at all:

const at = motions.follow(String(card), { x, y }, tick);
drawCard(at.x, at.y, card);

move(key, from, to, frame, delay?) is for grids, where a cell index is a place rather than a piece. Cell 12 holding a red gem this frame and a blue one the next is two different pieces, not one that travelled — follow would slide the red one across the board to become the blue one.

For grids you have to work out what moved. Two helpers do it:

  • columnFalls(before, after, size, empty) — for anything that drops into gaps. Walks each column from the bottom and pairs equal colours, because gravity cannot reorder a column. Returns what moved and what is new.
  • Your own diff for anything stranger. games/twenty-forty-eight/src/slides.ts is a worked example: 2048 needs its own because a merge means two tiles arrive in one cell, which no general helper handles.

Caveat: columnFalls pairs by colour, not identity. Two same-coloured pieces in one column can be paired the wrong way round. This is invisible — they are indistinguishable on screen and both end up in the same two cells either way — but do not build anything on the pairing being the correct one.

Stagger, and the trap in it

move takes a delay. Give a column increasing delays and it falls in sequence rather than as one slab.

Caveat: do not cap the delay with a constant. The obvious Math.min(5, size - 1 - row) saturates: on a ten-row board every row above the fourth gets the same delay and they all leave together, which is the exact thing the stagger exists to prevent. Scale to the board instead:

Math.round(((size - 1 - row) * 6) / size)

This was shipped wrong first. It looked fine in a unit test and was only caught by printing the actual delays for a real board.

Reset on a new board

case "init":
  motions.clear();   // or every piece flies in from where the last game left one

Feedback

Effects is what the board does about what just happened.

import { Effects, punch } from "@crazy8s/game-sdk";
const effects = new Effects();

Three things, and each answers a question the player is already asking:

Call Answers
say("+240", at, frame) How much was that worth?
burst(at, frame, { colour, count, spread }) What just happened, and how big was it?
punch(t) on the score counter Did that register?

Scale the burst to the score. A nine-tile clear that throws the same spray as a pair tells the player nothing. count and spread should both grow with what was earned.

Fire from the centre of what happened, not from the tap. A nine-tile group taken by its corner should sound off from its own middle.

Read the board before the update. Afterwards the pieces are gone and there is nothing left to point at:

const group = groupAt(state, row, col);        // before
const focus = centreOf(group);
state = game.update(state, applied, tick, prng);
effects.say(`+${points}`, focus, tick);        // after

Particles must never use your game's generator

Effects takes its random source as a constructor argument and defaults to Math.random. That is deliberate and it is not negotiable:

Drawing particle directions from prng would advance it and change the board. The shuffle, the refills, every future draw — all shifted, because of a spray nobody can see. Your run would then fail to verify.

Math.random is exactly right here. A spray that looks different on two devices is a spray. A spray that came out of the seeded stream is a bug in a staked game.

Nothing may accumulate

Popups and particles are dropped as they expire. If you write your own, test it: a board running for five minutes must not still be carrying every burst it ever threw. There is a 600-frame test in effects.test.ts doing exactly that.


Sound

Six synthesised voices — tap, pop, clear, win, fail, tick. No audio files.

import { Sound } from "@crazy8s/game-sdk";
const sound = new Sound();

Why there are no samples

A game runs in a sandboxed iframe with an opaque origin and no network at runtime, so every byte it plays must already be in the bundle. Short effects as audio files run to tens of kilobytes each — a set of six can exceed the entire rest of a puzzle game — and your players are paying for their data. The whole synth is a few hundred bytes.

It also needs no licence, no attribution, and no answer to "where did this come from".

Unlock from a gesture, every time

Browsers refuse audio before a user gesture. Call unlock() from inside a real event handler, and call it on every press, not once:

canvas.addEventListener("pointerdown", (event) => {
  sound.unlock();   // the first attempt may have been refused; the fifth may not be
  ...
});

Until it succeeds, every play is a silent no-op rather than an error. A game that threw on its first sound would break for anybody who had not tapped yet.

Pitch carries size

sound.play("clear", 0.85 + Math.min(1.2, points / 90));

A bigger clear sounds higher. The player learns how big their move was before they have read the number. Pitch is clamped either side, so a twelve-cascade is neither inaudible nor piercing.

Degrade to silence, never to a crash

Web Audio may be missing, blocked or refused. Every path here goes quiet instead of throwing. Check isAvailable() if you want to know; do not require it.

The player mutes the platform, not your game

Do not ship a game with sound and no way to turn it off. Unmutable audio is worse than none: a player on a bus with no way to silence it closes the tab, and a game they closed is a game they did not stake on.

You do not build the control. The platform owns the preference — a player who mutes one game means "mute this platform", and nine games with nine mute buttons is nine settings that will disagree — and puts the control over your frame. Your game owns the audio, because it is generated inside your sandbox and the host cannot reach it.

Two things are required of you, and certification checks both:

1 · Say so in your manifest.

{ "sound": true }

The mute control is shown only over a game that declares this. A control that changes nothing teaches a player that the control does not work, and they will not reach for it on the game where it would have.

2 · Follow the setting.

const sound = new Sound();
followHostSound(sound);

That is the whole of it. The platform sends a settings message immediately before init — so a muted player never hears the opening note — and again whenever they change it mid-run. followHostSound applies it to the Sound you pass and does nothing else.

It cannot touch your rules. The rules run on the server and never see a settings message; sound is render-only, and this keeps it that way.

This was a real gap rather than a hypothetical one. Nine first-party games shipped with sound and no way to silence it, while this page said in bold not to do that. The documentation was right and nothing enforced it — which is why pnpm certify now fails a bundle that builds a Sound without following the host, and one that makes a noise without declaring it.


Layout: check your indices are actually visible

If your game overlaps cards or tiles, the part a player can see is a strip, and anything outside it may as well not be drawn.

This is worth measuring rather than eyeballing. Crazy Eights fans horizontally at a pitch of 34% of a card's width, which drops to 26% when the hand is large enough to run out of room. A suit drawn at 82% across the card — where a real deck puts it — was invisible on every card but the last, so the suit of a hand could only be learned by playing it.

hand size → pitch → is a ~25px index visible?
   7–20   → 38.8px → yes
     26   → 29.8px → yes
     30   → 25.7px → yes, just
     32   → 24.1px → no

The fix depends on which way you fan:

  • Horizontal fan (constraint is width): stack rank above suit in the left column.
  • Vertical fan (constraint is height): rank and suit side by side across the top — stacking would push the suit below the fold, exactly as hidden as the corner it came from.

Caveat: there is a hand size past which no font fixes this. Work out where your layout breaks and write it in a comment next to the arithmetic, so whoever hits it reaches for a layout change rather than a smaller font.


Drawing things instead of loading them

The same argument as sound, and the platform's kit follows it throughout:

Thing Drawn as Why not an image
Card suits drawSuit — four bezier paths Font substitution makes typed suit characters render half as colour emoji and half as flat text, and which half varies by device
Trophies drawTrophy — four shapes × four metals No files, any resolution, themeable
Cards drawCard — gradient body, hairline edge 52 card images is ~400KB; the paths are 3.4KB and sharp at any pixel ratio

Caveat: the nonzero winding rule will bite you. A shape built from several subpaths — a club's stem over its lobes, a rosette's tails over its disc, a trophy's handles over its bowl — must have every subpath wound the same way round. One drawn the other way cancels against the rest and leaves a notch or a hole. This has caught three shapes in this codebase. A mirrored path is not the same winding as the one it mirrors.

Render your shape and look at it. It is not checkable by reading.


Two traps that are not about feel at all

Your tickRate lives in two places. init receives only config, and the manifest keeps tickRate a level above it — so your game carries its own default. Change the manifest without changing the default and every time-based score silently prices the clock wrong. Every game here has a test that reads its own manifest.json and compares. Copy it.

catalogue:check does not compare catalogue fields. It compares bundle hashes and artwork. A config-only change to something like a practice limit will report "matches" while the published registry still holds the old value. Query the registry if you need to be sure.


  • Writing deterministic server rules — the half this must not touch
  • Pitfalls, and how they were fixed — symptoms and causes, including several from this page
  • Trust model — what your game may and may not decide
  • Canvas 2D, and why
  • Unused time is worth points — the scoring shape most games here use