A tiny, dependency-free retro game engine in a single file. Drop it in with a
<script>tag β no build step, no npm required β and everything a small canvas game needs is there.
Retroix is a general-purpose 2D game engine for the browser. It bundles the
plumbing every small game re-implements β a DPI-aware canvas, a crash-safe game
loop, input, sound, physics, a camera, screens, leaderboards and more β so your
game is just its own update(dt) / render(ctx) and level data. It ships as one
UMD file (window.Retroix), works from a CDN or a bundler, and has zero
dependencies.
A whole gallery of games is built on it β they're the showcase, not the point. It gives you:
- π₯οΈ DPI-aware canvas β a fixed logical resolution scaled crisply to any size.
- π Render-safe game loop β
requestAnimationFramewith a clamped delta; can't die mid-frame. - β¨οΈ Input β keyboard (arrows + WASD β named actions), mouse and touch in logical coords.
- π 8-bit audio β synthesized on the Web Audio API, zero sample files: SFX presets (coin, jump, laser, explosion, powerup, hitβ¦), short jingles, and custom tones, with persistent mute/volume.
- π΅ Chiptune music β looping, multi-voice background tracks defined as data (no files): per-level tunes with crossfade, pause/resume, ducking under loud SFX, and an independent music volume under one master mute.
- π₯ Game feel β screen shake, flash, and freeze-frame (hitstop) to make hits land.
- π§² Arcade physics β gravity/friction/max-vel integration + axis-separated AABB resolution with
onGround/blockedflags. - πΊοΈ Tile grid β cellβworld coords, and
solids()that feeds collision rects straight into physics. - π₯ Camera β follow a target, clamp to level bounds,
begin/endworld transform. - π State machine β
title/play/pause/overwith enter/update/exit hooks. - β±οΈ Timers & tweens β
after/everycallbacks and eased property tweens. - π Collision β AABB / circle / circle-rect / point overlap tests.
- πΎ Storage β namespaced, JSON-friendly
localStoragefor best scores and settings. - π€ Autopilot β a secret-combo dev mode that turns a bot loose to play your game unattended: checks it's finishable, and surfaces crashes and soft-locks with an on-screen report.
- π Leaderboard β Supabase REST with a localStorage fallback (
top/submit/qualifies). - πͺ Screens β overlay manager, retro 3-initial high-score entry, and a leaderboard table renderer.
- β¨ gfx β
roundRect, a particle-burst system, and a fading toast helper. - π¨
retroix.cssβ the neon-on-dark theme (HUD, buttons, screens, initials, leaderboard).
β¦so a game is just its own update(dt) / render(ctx) and level data.
Seven full games run on the engine β each just a few hundred lines of game logic. They're the proving ground (and the fun part). βΆ Play them all in the live gallery.
![]() Blastix Β· run-and-gun platformer |
![]() Paddix Β· brick-breaker |
![]() Alienix Β· twin-stick survivor |
![]() Space-Jam Β· side-scrolling shmup |
![]() BombJack Β· vertical road shooter |
![]() Castle Β· maze chase & rescue |
![]() Nimix Β· beach game of Nim |
No build step required. Load it straight from a CDN β it's a UMD file, so
window.Retroix is available globally:
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/retroix@1.2.0/retroix.css">
<script src="https://cdn.jsdelivr.net/npm/retroix@1.2.0/retroix.js"></script>Or from the GitHub repo (no npm needed):
<script src="https://cdn.jsdelivr.net/gh/DanMat/Retroix@v1.2.0/retroix.js"></script>Or with a bundler:
npm install retroiximport Retroix from 'retroix';
import 'retroix/css';<canvas id="game" width="480" height="640"></canvas>
<script>
var view = Retroix.canvas('#game', 480, 640); // dpr-aware
var input = Retroix.input(view); // keyboard + pointer
var board = Retroix.leaderboard({ gameId: 'demo' }); // local unless Supabase configured
var x = 240, y = 320;
Retroix.loop(function (dt) {
var a = input.axis();
x += a.x * 200 * dt; y += a.y * 200 * dt;
var ctx = view.ctx;
ctx.fillStyle = '#0a0a1c'; ctx.fillRect(0, 0, 480, 640);
ctx.fillStyle = '#00e5ff'; Retroix.gfx.roundRect(ctx, x - 15, y - 15, 30, 30, 6); ctx.fill();
}).start();
</script>| Call | What you get |
|---|---|
Retroix.canvas(el, w, h) |
{ ctx, width, height, dpr, resize(), toLogical(cx,cy) } |
Retroix.loop(step, opts) |
{ start(), stop(), isRunning() } β step(dt) in seconds |
Retroix.input(canvas, opts) |
{ actions, pointer, down(a), axis(), onKeyDown(fn) } |
Retroix.audio(opts) |
{ coin(), β¦, play(name), jingle(name), tone(spec), sequence(notes), music(track), stopMusic(), pauseMusic(), resumeMusic(), duck(), musicVolume(v), mute(v), volume(v), toggle() } |
Retroix.tracks |
built-in chiptune loops: title, action, boss, calm |
Retroix.fx(dims, opts) |
{ shake(a), flash(color,s), freeze(dur), frozen(), update(dt), preRender(ctx), postRender(ctx) } |
Retroix.hit |
rects(a,b), circles(a,b), circleRect(c,r), pointRect(px,py,r), pointCircle(px,py,c) |
Retroix.physics |
body(opts), move(body, solids, dt) β arcade integration + AABB resolution |
Retroix.grid(cols, rows, tile) |
{ at, set, cellAt, rectOf, forEach, solids(isSolid, region) } |
Retroix.camera(view, opts) |
{ x, y, follow(t,l), clamp(), begin(ctx), end(ctx), worldToScreen, screenToWorld } |
Retroix.fsm(states, initial) |
{ set(name,β¦), update(dt), render(ctx), is(name), current() } |
Retroix.timer() |
{ after(s,fn), every(s,fn), tween(obj,props,dur,ease,done), cancel(t), update(dt) } |
Retroix.ease |
linear, inQuad, outQuad, inOutQuad, inCubic, outCubic, outBack, outBounce |
Retroix.storage(ns) |
{ get(key,fallback), set(key,val), remove(key), keys(), clear() } |
Retroix.autopilot(cfg) |
secret-combo test bot (finishability / chokepoints) β { start(), stop(), running() } |
Retroix.jumpReach(opts) |
{ maxDist, maxHeight, airtimeFrames, apexFrames } from jump physics |
Retroix.leaderboard(cfg) |
{ top(n), submit(initials,score,stage), qualifies(score), mode } |
Retroix.screens(root) |
{ show(id), hideAll(), current() } over [data-screen] elements |
Retroix.initials(el, {onEnter}) |
3-letter entry: { value(), reset(), handleKey(e), active } |
Retroix.renderLeaderboard(tbody, rows, opts) |
fills a <tbody> with score rows |
Retroix.gfx |
roundRect, particles() |
Retroix.toast(el) |
returns toast(msg, ms) |
Retroix.util |
clamp, rand, randInt, pick, hypot, dist2, lerp, shuffle, escapeHtml |
No files β every sound is synthesized. The first key/tap unlocks audio (browsers require a gesture); mute and volume persist across sessions.
var sfx = Retroix.audio(); // { volume: 0.35 } by default
sfx.coin(); // built-in presets
sfx.explosion();
sfx.play('laser'); // β¦or by name
sfx.jingle('win'); // short stingers: win / lose / levelup / gameover
sfx.tone({ wave: 'square', freq: 'C5', freqEnd: 'C6', dur: 0.15 }); // custom blip
sfx.sequence(['C5:0.08', 'E5', 'G5']); // your own melody
sfx.toggle(); // mute button; sfx.volume(0.5) to set levelA secret key combo (the Konami code by default) turns a bot loose to play
your game unattended β to check it's finishable and to surface crashes,
soft-locks and impassable spots. The engine can't win your game, so supply
a bot(api) policy + closures over your state. The recommended model: give the
game infinite lives (respawn in place) and pass deaths() (a monotonic
failure counter) + location(). The bot keeps trying; if it dies
deathsPerSpot times within one spot, that's flagged an impassable
chokepoint β with the coordinate to fix. A stuck-watchdog, timeout,
isWin(), and crash capture round it out, and the report names the worst
death hotspot. (No bot β a generic masher smoke-tests bootβplay.)
Retroix.autopilot({
start: function () { testMode = true; if (state === 'title') startGame(); }, // infinite lives
stop: function () { testMode = false; },
bot: botDrive, // uses grid/physics to jump gaps
progress: function () { return levelIndex * 1e5 + player.x; },
location: function () { return levelIndex * 1e5 + deathX; }, // where it last died
deaths: function () { return deathCount; }, // you bump this on each respawn
isWin: function () { return wonFlag; },
deathsPerSpot: 6
});
// toggle with β β β β β β β β B A. api: press/release/tap/only, t, frame.Pair it with a static reachability check so you catch impossible geometry without even running the bot:
var reach = Retroix.jumpReach({ jump: JUMP, gravity: GRAV, run: RUN });
// reach.maxDist β if a level gap is wider than this, the jump is impossible.Tracks are data, not files β a tempo and a step grid per voice. Each token
is one step: a note (C4, F#3), - for a rest, or . to hold the previous
note one more step. Give each level its own tune; switching crossfades.
sfx.music('action'); // a built-in loop from Retroix.tracks
sfx.music(level.track, { fade: 0.6 }); // β¦or your own, per level (crossfades)
sfx.pauseMusic(); sfx.resumeMusic(); // pause with the game
sfx.duck(0.4, 0.5); // dip music under a big hit (auto on explosion)
sfx.musicVolume(0.4); // music volume, independent of SFX
sfx.stopMusic(0.5);
// A track is just an object:
var boss = {
tempo: 150, stepsPerBeat: 4,
voices: [
{ wave: 'square', vol: 0.26, notes: 'C5 C#5 C5 C#5 C5 - G5 - G#5 G5 G#5 G5 - - F5 -' },
{ wave: 'sawtooth', vol: 0.30, notes: 'C3 . C3 . C3 . C3 . G#2 . G#2 . G2 . G2 .' }
]
};var fx = Retroix.fx(view); // pass the canvas api (or { width, height })
// on a big hit:
sfx.hit(); fx.shake(0.6); fx.flash('#fff', 0.4); fx.freeze(0.05);
// in the loop:
fx.update(dt);
if (!fx.frozen()) { updateWorld(dt); } // hitstop pauses the world, not the loop
fx.preRender(ctx); // shake offset applied
drawWorld(ctx);
fx.postRender(ctx); // flash overlay drawn on topBodies integrate gravity/friction and resolve against static rects. A grid turns your level into those rects for free:
var grid = Retroix.grid(40, 30, 16); // 40Γ30 tiles, 16px each
grid.set(5, 20, 1); // 1 = solid
var hero = Retroix.physics.body({ x: 80, y: 40, w: 12, h: 16, gravity: 0.6, friction: 0.2, maxVx: 4, maxVy: 12 });
Retroix.loop(function (dt) {
var a = input.axis();
hero.vx = a.x * 4;
if (input.down('up') && hero.onGround) { hero.vy = -11; sfx.jump(); }
var solids = grid.solids(null, { x: hero.x - 40, y: hero.y - 40, w: 120, h: 120 }); // only nearby tiles
Retroix.physics.move(hero, solids, dt);
// hero.onGround / hero.blocked.left|right|up|down are now set for this frame
}).start();Cap velocity (
maxVx/maxVy) below your tile size β arcade physics steps discretely, so an uncapped body can skip clean over a thin wall.
var cam = Retroix.camera(view, { bounds: { x: 0, y: 0, w: 640, h: 480 } });
cam.follow(hero, 0.1); // smooth follow
cam.begin(ctx); drawWorld(ctx); cam.end(ctx);
var game = Retroix.fsm({
title: { enter: function () { showScreen('title'); } },
play: { update: function (dt) { stepWorld(dt); } },
over: { enter: function () { sfx.jingle('gameover'); } }
}, 'title');
// game.set('play'); game.update(dt); if (game.is('play')) { β¦ }
var tm = Retroix.timer();
tm.tween(hero, { y: hero.y - 20 }, 0.3, 'outBack'); // little hop
tm.after(1.5, function () { game.set('over'); });
// in the loop: tm.update(dt);Retroix.leaderboard({
supabaseUrl: 'https://YOUR-PROJECT.supabase.co', // blank = local only
supabaseAnonKey: 'sb_publishable_...', // public by design
gameId: 'paddix',
size: 10
});The Supabase board expects a scores table (game, initials, score,
stage, created_at) protected by row-level-security. See any consuming game's
docs/supabase.sql.
retroix.css styles .rx-* classes and reads CSS variables you can override
(--rx-accent, --rx-pixel, --rx-panel, β¦) to reskin per game. Load the
Press Start 2P font for the headings.
MIT Β© DanMat






