Using the engine

Retro-64 is a folder of plain ES modules — no npm, no build step, no framework. Copy public/retro64/ to any static host (or serve it locally) and you have everything. This page is the complete quick-start; the engine's inner workings are covered milestone by milestone in the Build Log.

Get the engine

Four ways, all zero-install — use it, abuse it:

  1. The folder — copy public/retro64/engine/ and import module by module, exactly as the examples on this site do.
  2. Download the single file — the button below flattens every engine module into one retro64-engine.js, built fresh in your browser from the code this site is running right now. Drop it next to an HTML page and import everything from that one file. (The same button lives in the Map Editor, next to Export — a drawn map's JSON plus this one file is a game you can host anywhere.)
  3. Include it from here — download nothing: the INCLUDE button copies a one-import snippet that hot-links engine/index.js straight from this site. CORS is open on /public/retro64/*, so the import works from a page on any domain. Your game is one HTML file; the engine stays here.
  4. Just read itVIEW SOURCE puts the whole bundled engine on the page. Read it, pick it apart, take the bits you like. It's all vanilla JS; there is nothing to hide.

With the single file (or the include snippet), the import lines in every example on this site collapse to one:

import { createLoop, Video, Input, Camera, Player,
         TileMap, SOLID, PLATFORM, C64 } from './retro64-engine.js';

The 40-line game

A complete, playable platformer:

<!doctype html>
<div id="game"></div>
<script type="module">
  import { createLoop } from './retro64/engine/loop.js';
  import { Video }  from './retro64/engine/video.js';
  import { Input }  from './retro64/engine/input.js';
  import { Camera } from './retro64/engine/camera.js';
  import { Player } from './retro64/engine/player.js';
  import { TileMap, SOLID, PLATFORM } from './retro64/engine/tilemap.js';
  import { C64 } from './retro64/engine/palette.js';

  const map = new TileMap({
    rows: [
      '........................................',
      '........................................',
      '..............=====.....................',
      '........................................',
      '......=====.............................',
      '........................................',
      '########################################',
    ],
    legend: {
      '#': { flags: SOLID,    kind: 'brick',    base: C64.RED,    top: C64.LIGHT_RED },
      '=': { flags: PLATFORM, kind: 'platform', base: C64.YELLOW, top: C64.WHITE },
    },
  });

  const video  = new Video(document.getElementById('game'));
  const input  = new Input().attach(window);
  const camera = new Camera(video.width, video.height, map.pixelWidth, map.pixelHeight);
  const player = new Player(map, {
    maxSpeed: 1.7, accel: 0.09, friction: 0.06, airControl: 0.7,
    gravity: 0.14, jumpVel: 3.4, minJumpVel: 1.3,
    variableJump: true, fixedJump: false, maxFall: 4, color: C64.YELLOW,
  }, { x: 16, y: 20 });

  createLoop({
    update() { input.tick(); player.update(input); camera.follow(player.x, player.y); },
    render() { video.clear(C64.BLUE); map.draw(video, camera.x, camera.y); player.draw(video, camera.x, camera.y); },
  }).start();
</script>

That's the whole thing. A level is strings plus a legend; a character is ten numbers; the loop is two callbacks.

The three rules

  1. All physics values are pixels per 50Hz tick. The engine updates in fixed 20ms steps, so gravity: 0.14 means the same thing on every machine. Handy tuning maths: jump height ≈ jumpVel² / (2 × gravity), airtime ≈ 2 × jumpVel / gravity ticks.
  2. Colours are palette indices, not hex. C64.YELLOW, not #B8C76F. Sixteen colours; that's the machine.
  3. Rooms are character grids on 8×8 tiles. One screen is exactly 40×25 characters. Wider rooms scroll automatically; one-screen rooms pin the camera.

The module map

Module What it gives you
engine/loop.js createLoop({update, render}) — fixed 50Hz timestep
engine/events.js Emitter — the tiny event bus Player, World and Rules extend
engine/video.js Video — 320×200 canvas, integer scaling, palette drawing
engine/input.js Input — one-button joystick (keyboard-mapped, dir/jumpHeld/pressed)
engine/palette.js PALETTE, C64 — the 16 colours by name
engine/tilemap.js TileMap + tile flags — collision grid and room rendering
engine/camera.js Camera — follow + clamp scrolling
engine/player.js Player — the profile-driven controller, poses and all
engine/sprites.js paintFrames — frame-authored characters from pure JSON
engine/attacks.js Moveset — combat as data: stances, envelopes, cooldowns, the stomp
engine/entities.js Patroller, Item, Projectile, Turret, Chaser, Generator, Stalker, Fan and friends — enemies, machines and pickups
engine/outcomes.js Outcomes — deaths and departures for ANY body, box-scaled and deterministic
engine/world.js World — flick-screen multi-room games (milestone 2)
engine/rules.js Rules — declarative objectives and scoring (milestone 3)

Tile flags

Combine flags with | if a tile needs more than one behaviour:

  • SOLID — blocks from every side
  • PLATFORM — jump up through it, land on top of it
  • HAZARD — kills on contact
  • CONVEYOR — drags anything standing on it (dir: 1 or -1 on the tile, optional beltSpeed)
  • CRUMBLE — decays while stood on, then gives way (optional hp, in ticks)
  • LADDER — climbable; press up or down while overlapping
  • ICE — solid but low-grip; you slide (optional grip, 0..1 on the tile)
  • BOUNCE — springboard; launches on landing (optional bounceVel)

The physics profile

Every number the Player reads, in one place. This is where game feel lives:

{
  inertia: true,       // false = instant start/stop, purest 8-bit movement;
                       // true  = momentum via accel (speed-up) & friction (slow-down)
  maxSpeed: 1.7,       // top walking speed
  accel: 0.09,         // per-tick speed gain      (inertia: true only)
  friction: 0.06,      // per-tick speed loss with no input   (ditto)
  airControl: 0.7,     // 0..1 — steering strength mid-air (0 = none)
  gravity: 0.14,       // downward pull per tick
  jumpVel: 3.4,        // takeoff impulse (≈41px high at this gravity)
  variableJump: true,  // release early to cut the jump short
  minJumpVel: 1.3,     // the capped ascent for a tapped jump
  fixedJump: false,    // true = arc committed at takeoff (Jet Set Willy)
  maxFall: 4,          // terminal velocity
  climbSpeed: 1.1,     // ladder speed (milestone 2)
  deathTicks: 45,      // length of the death animation (0 = instant respawn)
  color: C64.YELLOW,   // suit colour of the placeholder sprite
}

Want to feel what each number does before writing code? That's exactly what the Movement Lab is for.

🕹️ Open the Movement Lab

Times, targets & scores

The game's contract with the player — a clock, a points table and a list of win conditions — is one more block of data, not code. Rules knows nothing about tiles or physics; it just watches events and answers "how am I doing?" and "have I won yet?":

import { Rules } from './engine/rules.js';

const rules = new Rules({
  timer: { seconds: 90, onExpire: 'die' }, // 'die' costs a life & re-arms;
                                           // 'end' is game over;
                                           // perRoom: true = Manic Miner air
  scoring: {
    collect: 100,        // per item (an item's own `points` overrides)
    roomVisit: 50,       // first entry into each room
    targetComplete: 300, // per target ticked off
    secondLeft: 25,      // × seconds left on the clock, paid once on the win
  },
  targets: [                                     // ALL must be met to win
    { type: 'collect', count: 3, tag: 'key' },   // n items (of a tagged kind)
    { type: 'reach', room: 'the-vault' },        // set foot in a room
    { type: 'visit', rooms: ['attic','cellar'] },// set foot in all of them
    { type: 'survive', seconds: 60 },            // stay alive this long
  ],
}).bind(world, player);

Omit targets and you get the classic default — [{ type: 'collect-all' }]. Omit timer and time is still counted (for the HUD and the bonus), it just never runs out.

bind(world, player) wires everything through the event bus: room entries and collects flow in from the World (each item's points and tag riding along), and a non-fatal countdown expiry kills the player through hurt({ type: 'time' }) — cancelable from a contact listener like any other hit. The only thing left to you is the clock: call rules.tick() once per update, next to world.update().

For the HUD, read rules.score, rules.timeLeft, rules.elapsed and rules.complete directly, or take rules.summary() — score, clock and a { label, done } line per target, ready to print. The Manor plays all of this: a 90-second clock, 100 a trinket, a golden one in the attic worth 500, and 25 × every second left when the last one falls.

Hooks & callbacks

Everything the engine does is observable. Player, World and Rules are event emitters — on(name, fn) subscribes (and returns an unsubscribe function), once(name, fn) hears one firing, and '*' hears everything. Listeners run inside the 50Hz update, so keep them cheap; a listener that throws is logged and skipped, never crashing the physics.

Player events

player.on('contact', (e) => sparks(e.x, e.y));   // touched something deadly
player.on('jump',    ({ type }) => blip(type));  // 'ground' | 'coyote' | 'ladder'
player.on('land',    ({ impact }) => thud(impact));
Event Fires when Payload
contact something deadly touches the player { type, source, x, y, player, cancel() }
die the death sequence starts { cause, deaths }
die-tick each tick of the sequence { t, progress }
death-finished sequence over, about to reset { cause }
respawn back at the spawn point { x, y }
jump / double-jump / wall-jump takeoff of each kind { type } / { remaining } / { wall }
land hit the ground after being airborne { impact }
bounce launched by a springboard { vel, impact }
climb-start / climb-end grabbed / left a ladder { x, y } / { reason }

Contact is a negotiation. Deadly touches route through player.hurt({ type, source, x, y }), which fires contact before anything dies. The payload carries the exact contact point and the thing that hit you — that's where impact effects fire from — and calling e.cancel() inside a listener makes the player shrug the hit off entirely (shields, mercy frames, power-ups).

Death is a sequence, not an instant. die() freezes the controller for profile.deathTicks (default 45 ≈ 0.9s) while a death animation plays; only then does the reset happen (player.onDeath if set, else respawn()). The built-in animation is a palette-flashing debris burst. Want your own? Assign one:

player.deathAnim = (video, camX, camY, t, progress) => {
  // draw anything, driven by t (ticks since death) and progress (0..1)
};

World, Rules, entity and tile hooks

world.on('collect',    ({ item, remaining }) => chime(remaining));
world.on('room-enter', ({ key, from }) => announce(key));
world.once('complete', rollCredits);

rules.onScore = (points, total) => hud.flash(points);   // every emitter event
rules.on('target-complete', ({ target }) => fanfare()); // has an onX twin

Entities keep plain callback properties (one listener is all a guardian needs), settable in the constructor opts or assigned later: Patroller/Chaser get onTurn(dir, self); Turret gets onFire(projectile, turret) — the muzzle-flash moment — plus projOnDeath, handed to every projectile it fires; Projectile gets onDeath(reason, self) and a kill(reason) method so your game can retire one with a reason ('tile', 'hit', 'offscreen') and burst an effect at its point of death. TileMap gets onCrumble(cx, cy, tile) for the dust puff as a crumbling floor gives way.

The Danger Room example wires all of this together: turret muzzle flashes, projectile impact bursts, and a spark shower at the exact point of every fatal contact.