indie-arcade-craft
v0.1.0
Published
Deterministic engine parts and a gate framework for small web arcade games that play like well-reviewed indie games: fixed-step loop, seeded streams, state hashing, input sampling, juice budgets, human-limit bots, and gates proven by mutants.
Maintainers
Readme
indie-arcade-craft
Deterministic engine parts and a gate framework for small web arcade games that should play like well-reviewed indie games, not like ad units.
Zero dependencies. ES modules. Node 18 or newer, and any browser that runs modules. No browser globals are touched at import time, so everything below runs the same in a test as in a page.
npm install indie-arcade-craftThe idea
A small game feels good when a few things are true, and each of them can be checked by a machine:
- The game is deterministic. The same seed and the same inputs give the same state at every tick, at 30, 60 or 144 fps. Then you can prove it with a hash, record golden replays, and replay a prize draw on a server.
- Input is judged where the finger was, not where the frame drew. Touching down never moves the thing you drag.
- Effects have budgets. Hitstop, haptics and shake are capped, so the loud moments stay loud.
- Difficulty is a table. The spawner, the fairness checks and the test bots all read one phase table.
- Bots play with human limits. A bot that reacts in one frame proves nothing about a thumb.
- Gates can fail. Every gate is tied to a deliberate bug (a mutant). If a gate stays green on its own bug, the proof run says so.
This package is those parts. It is not a renderer, an audio engine or a game framework: you bring the drawing.
Quickstart
A complete headless game built from the package is in examples/mini-catch. Run its gates, and
prove they can fail:
npx arcade-gates node_modules/indie-arcade-craft/examples/mini-catch/gates.mjs --provePASS D1 must same seed, same run: identical state hash at tick 600
PASS D2 must frame-rate independent: 60, 144 and 30 fps pacing give the same hash
PASS B1 must the reward resolves exactly once (and does resolve, so this gate is not vacuous)
PASS P1 should the difficulty curve is a sawtooth with a calm start and a rush peak
PASS F1 must a median human can reach the reward on at least 60% of seeds
5 passed, 0 failed, score 100/100, musts 4/4
mutant proofs: 5/5 caughtThe five mutants are real bugs: unseeded randomness, spawning that depends on the frame count, a reward that resolves twice, a difficulty curve with no breathers, and a game too fast for a human to react to.
A game in six lines
import { createClock, createLoop, createBus, createStreams, rngNext, hashState } from 'indie-arcade-craft';
const clock = createClock();
const streams = createStreams(42); // sim and cosmetic randomness, kept apart
const S = { tick: 0, x: 0, sim: streams.sim }; // all gameplay state, plain data
const bus = createBus({ stamp: () => ({ tick: S.tick }) });
const loop = createLoop({
clock,
step: () => { S.tick++; S.x += rngNext(S.sim) - 0.5; }, // one tick of the simulation
tick: () => S.tick,
hash: () => hashState(S),
render: (alpha) => { /* draw S, interpolating by alpha */ },
isActive: () => true,
});
loop.start(); // requestAnimationFrame in a page
// in a test: loop.setManual(true); loop.advance(3000, 1000 / 144); loop.hashAt(150)Modules
Every module is also a subpath import, for example indie-arcade-craft/loop.
| Module | What it gives you |
|---|---|
| rng | mulberry32, rngNext over an Int32Array(1) so the stream position lives in hashed state, createStreams (sim and fx streams from one seed), mix32, noise1, hashSeed, pickWeighted, intRange |
| hash | hashState (canonical walk: sorted keys, Float64 bits, _-prefixed keys skipped, throws on functions and cycles), buildRules (a frozen copy of your design numbers stamped with a cfgHash), canonical, fnv1a, deepFreeze |
| clock | One clock. Timers fire in order, errors are contained, fromWall maps DOM timestamps onto game time. Nothing in the game uses setTimeout |
| bus | A typed event ring (emit, on, entries, count). Gates assert on it; end screens rebuild their stats from it |
| pool | A fixed-capacity pool that reports overflow |
| loop | Fixed 60 Hz step on an accumulator. At most 5 steps per frame, a long gap pauses instead of catching up, a throwing step is rolled back and halts the loop, manual stepping for tests, and a ring of per-tick hashes |
| input | createSampler (timestamped samples, interpolated at the step time), createRelativeDrag (no jump on touch-down, slop, tap detection, wall re-anchoring), attachPointer (pointer events, coalesced events, capture) |
| juice | createTrauma (shake is trauma squared), createHitstop (rolling-window budget), createHapticBudget (per run and per second), createLadder (a pentatonic pitch ladder that resets on a miss) |
| pacing | phaseAt, lerpField, checkSawtooth (contiguity, breather ratio, calm start, expected peak, longest stretch), readableTime |
| bots | PROFILES (pro, median, casual), createHuman (reaction, hand speed, aim error, misreads, attention lapses, all seeded), and profilesHash to lock the table |
| gates | defineGate, runGates, proveMutants |
| qa | installQaHook (a production stub, or a dev surface with stepping, hash lookup and a live tainted flag) and canReport (tainted, manual and local runs never reach analytics) |
Gates and mutants
import { defineGate, proveMutants } from 'indie-arcade-craft';
export const gates = [
defineGate({ id: 'B1', title: 'reward resolves once', severity: 'must',
run: (ctx) => ({ pass: ctx.resolves === 1, detail: `${ctx.resolves} resolves` }) }),
];
export const mutants = [
{ id: 'double-resolve', breaks: ['B1'], apply: (ctx) => ({ ...ctx, resolves: 2 }) },
];
export const makeCtx = () => ({ resolves: 1 });arcade-gates <module> [--only ID,ID] [--prove] [--json] exits 0 when every must gate passes and, with
--prove, every mutant is caught. It exits 1 otherwise and 2 on bad input. Gates are plain async functions over a
context you build, so they work with any runner, including a headless browser.
A gate should also not pass for the wrong reason. If a gate checks "resolves at most once" and nothing ever resolves,
it is vacuously green. The example's B1 requires at least one resolve for exactly that reason.
Contracts
- Hash stability. What
hashStateandbuildRulesreturn for the same input is a contract, because golden replays store it. A change is a major version. Strings are hashed over both bytes of each UTF-16 unit. - Two streams. Anything cosmetic must use the
fxstream (or none), never thesimstream, and cosmetic state must stay out of the hashed state (or under a_key). Otherwise a visual tweak moves your goldens. - One clock. Schedule through
clock.after. Direct timers make manual stepping lie. loop.advance(ms)moves a whole number of frames, so the time moved ismsrounded up to a multiple of the frame length.
Not in 0.1
No renderer, audio engine, UI kit or browser gate runner. Those depend on a game's art and shell, and are better left to the game. A headless-browser runner for gates (driving a page through its QA hook) is the likely next piece.
Where the numbers come from
Defaults such as the 60 Hz step, 5-step cap, 250 ms gap, the 14-ticks-in-120 hitstop budget, the haptic budget and the bot reaction times (0.20, 0.26 and 0.35 s) come from tuning a series of small games on low-end phones. Hand speed, aim error, lapses and misread rates are starting values: refit them from your own playtests, and update the pinned profile hash when you do.
The game-feel ideas have public sources: Steve Swink, Game Feel (2008); Jan Willem Nijman, "The Art of Screenshake" (2013); Dominic Kao, "The effects of juiciness in an action RPG", Entertainment Computing 34 (2020).
License
MIT. See LICENSE.
