punter.js
v5.0.0
Published
A teaching-focused JavaScript game engine for the browser
Downloads
4,424
Maintainers
Readme
punter.js
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.
Game programming terms
- 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
Examples
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.
Quick Start
<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>1. Setup
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.
2. Scenes
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.
3. Sprites
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).
Vector sprites
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.
sprite methods
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
sprite properties
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
4. Input
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.
key names
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)
keyboard
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())
pointer (mouse & touch)
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
5. Sound
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 }.
6. Events
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);
});
});Gotchas for Beginners
- 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
How the Game Loop Works
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.
Technical details
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.
Responsive Canvas
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.
Debug Mode
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.
Other engine properties
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'
License
MIT License. Built by the Orca Scan Team.
