A teaching-focused JavaScript game engine for the browser.
Created by the Orca Scan team, Punter.js powers OrcaCam and was designed to help students and new developers learn JavaScript by making games. Its deliberately simple design keeps the important parts visible instead of hiding them behind frameworks and abstractions.
- Canvas - an HTML element you draw on with JavaScript. Every punter.js game lives inside one
<canvas> - Sprite - a game object (player, enemy, coin). It has a position, can move, animate and collide
- Scene - one screen of your game (menu, level, game over). Sprites and logic are set up inside a scene
- Game loop - code that runs 60 times per second to move sprites, check collisions and redraw the screen
This project includes a few example games which you can play and edit in the browser at https://punterjs.org. Or, you can download and run the examples locally using:
git clone https://github.com/orca-scan/punter.js.git
cd punter.js
npm install
npm startThis starts a local server on port 4000 and opens the games index in your browser.
<canvas id="game"></canvas>
<script src="punter.js"></script>
<script>
// load assets and bind to a canvas (must be called first)
punter.setup({
canvas: '#game',
debug: false, // set true to see FPS, bounds boxes, sprite labels
images: {
player: 'images/player.png',
enemy: 'images/enemy.svg'
},
sounds: {
jump: 'sounds/jump.mp3'
}
});
// define a scene - create sprites and register update/draw handlers inside
punter.scene('level1', function () {
// create a sprite using the 'player' image loaded above
var player = punter.createSprite({
id: 'player', // optional - auto-generated if omitted; must be unique if provided
image: 'player', // key from config.images
x: 50, // pixels from left edge of canvas
y: 50 // pixels from top edge of canvas
});
// runs ~60 times/sec - move things, read input, check collisions
punter.on('update', function () {
if (punter.isKeyDown('right')) player.moveX(2);
});
// runs once per frame after sprites draw - use for score text, HUD, overlays
punter.on('draw', function (ctx) {
// ctx is the canvas 2D context
});
});
// go() a specific scene - can be called immediately
punter.go('level1');
</script>Call punter.setup(config) once at the start. It loads images and sounds ahead of time, sets up the canvas, and fires the ready event when everything's finished loading. Config options are:
| Option | Type | Description |
|---|---|---|
canvas |
string/element | CSS selector or a <canvas> element |
debug |
boolean | Shows FPS counter, sprite bounds, and position labels |
images |
object | { key: imageUrl } map - preloaded before ready fires |
sounds |
object | { key: soundUrl } map - decoded before ready fires |
The images and sounds options are just name-to-file lookups - you pick a short name (like player) and point it at the actual file (like images/player.png), then refer to it by that short name everywhere else in your code.
A scene is a named function that sets up update/draw handlers and creates sprites - think of it as "everything that happens on this particular screen of the game."
punter.scene('menu', function () {
// set up sprites and handlers here
});
punter.go('menu'); // destroys sprites from the previous scene, then switches and starts the loopscene() just registers a scene under a name - it doesn't run anything yet. go() is what actually switches to it and starts things moving. You'll usually have several scenes (a menu, a level, a game-over screen) and use go() to jump between them, e.g. punter.go('gameOver') when the player loses.
punter.go() can be called before setup() has finished loading - it queues the scene name and runs it automatically once assets are ready, so you don't need to wait on the ready event just to call go(). It always tears down every sprite from the current scene and clears input state before running the new scene's handler - scenes don't need to clean up after themselves, the engine does it for you.
A sprite is any game object with a position - the player, an enemy, a coin, a wall. Tell it how to look (an image, a vector draw function, or both) and where to place it (x, y). Give it an id if you need to look it up later - otherwise the engine generates one for you.
var enemy = punter.createSprite({
id: 'enemy1', // optional - auto-generated if omitted; useful when you need punter.getSprite('enemy1') later
image: 'enemy', // matches a key from config.images (or an array of keys for animation)
vector: null, // optional draw function - see "Vector sprites" below
x: 100, y: 200, // position (number or '%' string, e.g. '50%')
w: 32, h: 32, // optional size (auto-detected from image if omitted; required for vector-only)
preserveAspect: true, // maintain image aspect ratio (default: true)
collidable: true, // computes bounding box for collisions (default: true)
boundsMode: 'pixel', // 'pixel' = edge-aware collisions from opaque pixels, 'rect' = full sprite w/h
repeatX: false, // tile horizontally across the canvas (default: false)
repeatY: false, // tile vertically across the canvas (default: false)
clipHeight: null, // clip sprite to this height in pixels (null = no clip)
clipFrom: 'bottom', // clip from 'top' or 'bottom' (default: 'bottom')
outline: 'red' // optional debug outline color
});x and y are the sprite's position in pixels, measured from the top-left corner of the canvas - so x: 0, y: 0 is the top-left, and increasing x moves right, increasing y moves down. Once created, enemy is a normal JavaScript object you keep a reference to and call methods on, like enemy.moveX(5).
Not every sprite needs an image file. Pass a vector function to draw the sprite with canvas code - useful for simple shapes, procedural graphics, or games that don't use image files at all. The engine calls it each frame with (ctx, w, h), where ctx is the canvas context already translated to the sprite's top-left corner.
// a triangle drawn with code instead of a PNG
var ship = punter.createSprite({
id: 'ship',
x: 100, y: 200,
w: 36, h: 36,
vector: function (ctx, w, h) {
ctx.strokeStyle = '#00ffcc';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(w / 2, 0); // tip
ctx.lineTo(w, h); // bottom-right
ctx.lineTo(0, h); // bottom-left
ctx.closePath();
ctx.stroke();
}
});When both image and vector are set, the image draws first and the vector function draws on top - useful for overlays like health bars or damage effects. Vector-only sprites default to boundsMode: 'rect' since there is no image to scan.
| Method | Description |
|---|---|
sprite.moveX(dx) |
Move right (+dx) or left (-dx) by pixels |
sprite.moveY(dy) |
Move down (+dy) or up (-dy) by pixels |
sprite.center(offsetX?, offsetY?) |
Centre on canvas; use centerX() / centerY() for one axis |
sprite.animate(delayMs) |
Advance animation frame; requires image to be an array |
sprite.bounce(range, speed) |
Smooth up-and-down float anchored to creation position |
sprite.blink(ms?, durationMs?) |
Flash on/off every ms ms (default 130); auto-stops after durationMs if given, otherwise blinks indefinitely; call blink(0) to stop early |
sprite.scroll(options) |
Scroll and optionally loop ({ speedX, loop: true }) or respawn ({ speedX, respawnAfter: ms, offset: n }) |
sprite.rotate(amount) |
Add amount (radians) to sprite.angle; vector functions can read this.angle directly |
sprite.isCollidingWith(other) |
true if collision areas overlap; in pixel mode transparent corners are ignored, other can be a sprite or a plain { x, y, w, h } rect |
sprite.destroy() |
Remove from engine; further draw() calls are silently ignored |
| Property | Description |
|---|---|
sprite.visible |
true if any part of the sprite is within canvas bounds |
sprite.seen |
true once the sprite has been visible at least once. Read/write |
sprite.actualW |
Drawn width in pixels, same as w |
sprite.actualH |
Drawn height in pixels, accounting for clipHeight if set |
Input is how your game reads the keyboard and mouse/touch. Use punter.isKeyDown() inside your update handler to check which keys are held, and punter.pointer to read mouse/touch position and taps.
punter.on('update', function () {
// keyboard - pass one or more key names (true if any are held)
if (punter.isKeyDown('left', 'a')) player.moveX(-3);
if (punter.isKeyDown('right', 'd')) player.moveX(3);
if (punter.isKeyDown('up', 'w')) player.moveY(-3);
if (punter.isKeyDown('down', 's')) player.moveY(3);
if (punter.isKeyDown('space')) punter.playSound('jump');
// use + for combos where all keys must be held at once
if (punter.isKeyDown('shift+a')) player.moveX(-6);
// mouse/touch - clicked is true for one frame after a tap or click
if (punter.pointer.clicked) {
player.x = punter.pointer.x;
player.y = punter.pointer.y;
}
// swipe detection - fires once on release (good for mobile)
if (punter.pointer.swipedLeft) player.moveX(-5);
if (punter.pointer.swipedRight) player.moveX(5);
});isKeyDown() takes friendly key names like 'left', 'space', 'a' etc. Pass multiple names to check if any of them are held (OR logic), or join names with + for combos that require all keys at once (AND logic). The pointer properties work on both desktop (mouse) and mobile (touch) automatically.
| Name | Key |
|---|---|
'left' 'right' 'up' 'down' |
Arrow keys |
'space' |
Spacebar |
'enter' |
Enter |
'escape' / 'esc' |
Escape |
'tab' |
Tab |
'backspace' |
Backspace |
'shift' 'ctrl' 'alt' |
Modifier keys |
'a' – 'z' |
Letter keys (case-insensitive) |
| Property/Method | Description |
|---|---|
punter.isKeyDown('left', 'a') |
true while any of the given keys is held |
punter.isKeyDown('shift+a') |
true while all keys in the combo are held |
punter.clearInput() |
Resets all key and pointer states (called automatically on go()) |
| Property | Description |
|---|---|
punter.isPointerDown(button) |
true while a button is held ('left', 'right', 'middle') |
punter.pointer.x |
Current pointer X in canvas pixels |
punter.pointer.y |
Current pointer Y in canvas pixels |
punter.pointer.clicked |
true for one frame after a tap or click (not set by swipes) |
punter.pointer.down |
true while the primary button or finger is held |
punter.pointer.swiped |
true for one frame after a swipe |
punter.pointer.swipedUp |
true if the swipe direction was up |
punter.pointer.swipedDown |
true if the swipe direction was down |
punter.pointer.swipedLeft |
true if the swipe direction was left |
punter.pointer.swipedRight |
true if the swipe direction was right |
punter.pointer.swipeDistance |
Distance of the swipe in canvas pixels |
punter.playSound('jump', { volume: 0.8, loop: false, speed: 1 });
punter.stopSound('jump');playSound takes the short name you gave a sound in setup()'s sounds option, plus optional settings - volume (0 to 1), loop (repeat forever or not), and speed (1 is normal, 2 is double speed, etc.).
Short sound effects layer automatically — up to 3 instances of the same sound can overlap at once (useful for rapid-fire collisions). The 4th call evicts the oldest. Sounds played with { loop: true } restart instead of stacking, as does any call with { restart: true }.
punter.on(eventName, fn) Events are how you plug your own code into the engine's game loop - you give it a function, and the engine calls that function at the right moment. Register update and draw inside a scene function so they're set up fresh each time the scene starts. The ready, resize, and go events can be registered anywhere.
| Event name | When it fires |
|---|---|
ready |
Once, after all assets are loaded. Not needed just to call go() |
update |
~60 times/sec (fixed timestep) - put game logic here |
draw |
Once per animation frame, after sprites auto-draw - use for HUD/overlays |
resize |
Whenever the canvas resizes |
go |
Whenever punter.go() switches scenes |
// these can be registered anywhere - they are not scene-specific
punter.on('ready', function () {
// all images and sounds are loaded - safe to start
punter.go('level1');
});
punter.on('resize', function () {
// canvas resized - reposition any manually-placed elements
player.center();
});
punter.on('go', function (sceneName) {
// fires after every scene switch - useful for analytics or shared teardown
});
// update and draw must go inside a scene
punter.scene('level1', function () {
var player = punter.createSprite({ image: 'player', x: 50, y: 50 });
var enemy = punter.createSprite({ image: 'enemy', x: 200, y: 100 });
var score = 0;
punter.on('update', function () {
// runs ~60 times/sec - move things, read input, check collisions
if (punter.isKeyDown('right')) player.moveX(3);
if (player.isCollidingWith(enemy)) punter.go('gameOver');
});
punter.on('draw', function (ctx) {
// runs after sprites draw - ctx is the canvas 2D context
// use for score text, HUD, overlays; sprites draw themselves automatically
ctx.fillStyle = 'white';
ctx.fillText('Score: ' + score, 10, 20);
});
});- Sprite IDs must be unique - reusing one throws an error
repeatXandrepeatYare mutually exclusive on a single sprite- Percentage strings (
'50%') forx/y/w/hare resolved relative to canvas size - Don't manually resize the canvas - the engine owns that via its own
resize()handling punter.setup()must be called beforecreateSprite,playSound,pauseetc
Once you call punter.go(), the engine runs a loop 60 times every second. Each loop does this:
- Runs your
updatecode (move things, check collisions) - Clears the screen
- Draws all sprites
- Runs your
drawcode (score, HUD) - Starts again
Each update always moves the game forward by the same amount of time - so your game runs at the same speed on fast and slow devices. Without this, a slow phone would make everything move in slow motion.
Use punter.pause() and punter.resume() to freeze and unfreeze the loop - good for a pause menu.
punter.redraw() redraws the screen without running any game logic - useful when the game is paused.
You do not need to understand this section to build a game. These are some of the details Punter.js handles for you:
- Content-trimmed + edge-aware collision data.
getBoundsdoes a per-pixel alpha scan once per image key, then caches both a tight content box and a compact occupancy mask. Runtime collision still starts with a fast AABB overlap, butpixelmode adds a lightweight edge-aware check so transparent corners are less likely to register as hits. - A real fixed-timestep loop. The loop runs
updateon a 60Hz accumulator, decoupled fromdraw, with a 100ms-per-frame cap to avoid a spiral of death after a tab is backgrounded. Game logic behaves the same on a 60Hz and 144Hz screen - most hand-rolled canvas code skips this and gets frame-rate-dependent movement. - Pointer, touch, and stylus are unified. One
keys/pointerinput model backed by the Pointer Events API (with mouse/touch fallback for older browsers), with coordinate mapping that accounts for canvas CSS scaling (getBoundingClientRect+ scale factor). Covers mouse, touchscreen, and stylus/pen with no extra game code. Tap vs swipe is classified on release using a distance threshold scaled to canvas width —pointer.clickedfires for taps, theswiped*properties fire for swipes, and they are mutually exclusive. - DPR-aware canvas buffer. The canvas backing buffer is sized at
internalResolution × devicePixelRatio(capped at 2x) so art stays sharp on retina screens. All game coordinates - sprite positions, movement, pointer, and custom canvas drawing - use logical pixels; the engine handles the physical scaling internally.
None of this is exotic, it's just the stuff that's easy to get wrong when you write it yourself.
The canvas always matches the real screen's aspect ratio - landscape screens get a landscape canvas, portrait screens get a portrait canvas. There's no portrait lock and no letterboxing (black bars).
Internally, resize() uses a 375×667 reference resolution purely as a starting point for arithmetic: it picks whichever axis (width or height) that reference matches the current orientation on, holds that axis fixed, and computes the other axis from the screen's actual ratio. The result is an internal resolution whose aspect ratio equals the screen's aspect ratio exactly, every time - 375×667 never appears in the final canvas size except by coincidence.
The backing buffer is additionally scaled by devicePixelRatio (capped at 2x) for crisp rendering on retina displays. Sprites automatically reposition/resize on resize - you don't need to do anything manually.
Set debug: true in setup() (or punter.debug = true later) to see:
- FPS, frame count, canvas size, and orientation (bottom-right overlay)
- Position/size labels under each sprite
To see collision outlines on a specific sprite, set outline: 'red' in its options — this works independently of debug mode and is useful for checking why collisions aren't firing.
This is genuinely useful while learning - turning it on lets you see the invisible collision boxes and frame rate, which makes it much easier to understand why something isn't colliding the way you expect.
Debug mode, device type, and orientation are also reflected as data-punter-* attributes on <html> (e.g. data-punter-device, data-punter-orientation, data-punter-scene) and as --punter-vpw / --punter-vph CSS custom properties, if you want to hook into them from your own CSS.
punter exposes a handful of read-only properties and helpers beyond what's covered above:
| Property | Description |
|---|---|
punter.currentScene |
name of the currently active scene |
punter.getSprite(id) |
get a sprite by id, null if not found |
punter.width |
logical pixel width of the canvas |
punter.height |
logical pixel height of the canvas |
punter.frame |
total frames since the loop started (never resets) |
punter.canvas |
the <canvas> element |
punter.paused |
true if the loop is paused |
punter.isMobile |
true on mobile devices |
punter.orientation |
'portrait' or 'landscape' |
MIT License. Built by the Orca Scan Team.
