retro-pong-game
v4.2.1
Published
A retro-style Pong game for the browser. You can drop it onto any web page in a easy way.
Maintainers
Readme
retro-pong-game
A retro-style Pong game that runs in any browser. Drop it onto your website with a single constructor call. No frameworks, no extra configuration required.

- Animated starfield or nebular background
- Particle effects on paddle hits
- Countdown timer with configurable duration
- Adaptive AI difficulty (auto-adjusts after scoring streaks)
- Power-ups and power-downs that randomly appear in the field
- In-game settings panel (changes saved to
localStorage) - Stereo-panned sound effects
- Fully configurable colors, sizes, and speeds
- TypeScript types included
How to play
You control the bottom paddle using the arrow keys. The top paddle is the AI opponent.
Goal: score as many points as possible before the timer runs out. A point is scored when the ball passes the opponent's paddle and exits through their side of the field. The player with the most points when the timer reaches zero wins.
Controls:
| Key | Action |
| --- | --- |
| ← Arrow Left or A | Move paddle left |
| → Arrow Right or D | Move paddle right |
| P or Space | Pause / resume |
| B | Cycle ball image |
Scoring streaks affect the AI: if you score three times in a row the AI gets harder; if the AI scores three times in a row it gets easier, so the game stays competitive.
Power-ups and power-downs appear randomly in the field every 12 seconds. Steer the ball into them to activate the effect. Power-ups (bright solid circles) give you a temporary advantage. Power-downs (dark circles with a rotating dashed ring) make the game harder for a few seconds. See Power-ups & power-downs for the full list.
Installation
npm install retro-pong-gameQuick start
1. Add a container element to your HTML
<div id="game"></div>The game appends a <canvas> inside this element automatically.
2. Import and create the game
New to this package? Start with isAdmin: true to configure everything visually first.
Fun tip: press
Bduring any game to cycle the ball through some images. Press again to go to the next image; one more press after the last image clears it back to a plain ball.
import { PongGame } from 'retro-pong-game';
const game = new PongGame({ isAdmin: true }, '#game');Open the settings panel (gear icon), adjust colors, sizes, and speeds live, then click Copy at the bottom to export your config as JSON. Paste it into your constructor and remove isAdmin:
const game = new PongGame(
{
/* paste your copied config here */
},
'#game'
);If you already know your config, pass it directly:
const game = new PongGame(
{
canvasWidth: 600,
canvasHeight: 400,
hasSound: true,
colors: {
background: '#1a1a2e',
paddle: '#e94560',
ball: '#e94560',
},
},
'#game'
);3. Clean up
Call destroy() before removing the element from the page:
game.destroy();CDN (no npm required)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Pong</title>
</head>
<body>
<div id="game"></div>
<script src="https://unpkg.com/retro-pong-game/dist/pong-game.umd.js"></script>
<script>
const game = new PongGame.PongGame(
{ canvasWidth: 600, canvasHeight: 400 },
'#game'
);
</script>
</body>
</html>Self-hosting: all audio and image assets are bundled inline in the JS file. You only need to serve a single file —
pong-game.umd.js(orpong-game.es.jsfor ES modules). CDN providers (unpkg, jsDelivr) work out of the box as well.
Framework examples
React
import { useEffect, useRef } from 'react';
import { PongGame } from 'retro-pong-game';
export default function PongWidget() {
const gameRef = useRef(null);
useEffect(() => {
gameRef.current = new PongGame(
{ canvasWidth: 600, canvasHeight: 400 },
'#pong-container'
);
return () => {
gameRef.current?.destroy();
};
}, []);
return <div id="pong-container" />;
}Vue 3
<template>
<div id="pong-container" />
</template>
<script setup>
import { onMounted, onBeforeUnmount } from 'vue';
import { PongGame } from 'retro-pong-game';
let game = null;
onMounted(() => {
game = new PongGame({ canvasWidth: 600, canvasHeight: 400 }, '#pong-container');
});
onBeforeUnmount(() => {
game?.destroy();
});
</script>Vanilla HTML + ES modules
<div id="game"></div>
<script type="module">
import { PongGame } from '/node_modules/retro-pong-game/dist/pong-game.es.js';
const game = new PongGame({ canvasWidth: 600, canvasHeight: 400 }, '#game');
</script>TypeScript
Import PongGameConfig for full type safety and editor autocomplete on every option:
import { PongGame, PongGameConfig } from 'retro-pong-game';
const config: PongGameConfig = {
autoSize: false,
canvasWidth: 400,
canvasHeight: 500,
paddleWidth: 70,
paddleHeight: 10,
ballDiameter: 7,
ballSpeed: 3,
paddleMoveStep: 4,
timer: {
duration: '1:30',
hasBackgroundCircle: true,
labelColor: '#ffffff',
labelFontSize: 12,
circleColor: '#62cb31',
circleLineWidth: 5,
circleBackgroundColor: '#333',
},
colors: {
paddle: '#4daae8',
ball: '#ff7a59',
background: '#222',
centerline: '#9e9c9c',
score: '#9e9c9c',
},
animatedBackground: {
starfield: true,
nebular: false,
},
difficultyLevel: 1,
particleBounce: true,
particleConfig: {
particlesCount: 20,
},
hasSound: true,
onSoundToggle: (enabled: boolean) => {
// enabled = true → sound is ON
// enabled = false → sound is OFF (muted)
console.log('Sound toggled:', enabled);
},
};
const game = new PongGame(config, '#game');Configuration
All options are optional. Omitted values fall back to built-in defaults.
new PongGame(config: PongGameConfig, selector: string)The config is deep-merged in this order (later layers win):
- Built-in defaults
- Config you pass to the constructor
- Settings the player saved through the in-game settings panel (
localStorage)
PongGameConfig
| Option | Type | Default | Description |
|---|---|---|---|
| autoSize | boolean | false | Size the canvas to fill its container element at startup |
| autoResize | boolean | false | Automatically resize the canvas when the container changes size (uses ResizeObserver) |
| canvasWidth | number | 400 | Canvas width in px (ignored when autoSize is true) |
| canvasHeight | number | 300 | Canvas height in px (ignored when autoSize is true) |
| paddleWidth | number | 70 | Paddle width in px |
| paddleHeight | number | 10 | Paddle height in px |
| ballDiameter | number | 7 | Ball diameter in px |
| ballSpeed | number | 3 | Starting ball speed (px per frame) |
| paddleMoveStep | number | 4 | Paddle speed while an arrow key is held (px per frame) |
| difficultyLevel | number | 1 | Starting AI difficulty; higher = faster and more precise AI |
| particleBounce | boolean | true | Particle burst when the ball hits a paddle |
| powerUps | boolean | true | Enable power-ups and power-downs that randomly appear in the field |
| hasSound | boolean | false | Enable sound effects |
| ballImage | string | "" | URL of a custom image to display on the ball from game start. Clipped to a circle and rotated with the ball. Pressing B still cycles through built-in images. |
| showSettings | boolean | true | Show the gear icon that opens the in-game settings panel. Set to false to lock the settings from players |
| isAdmin | boolean | false | Unlock difficulty and timing controls in the settings panel, plus a JSON export of the current config (see Admin mode) |
| onSoundToggle | (enabled: boolean) => void | - | Called when the player toggles the sound icon |
| timer | TimerConfig | see below | Countdown timer settings |
| colors | ColorConfig | see below | Colors for game elements |
| animatedBackground | AnimatedBackgroundConfig | see below | Background animation |
| particleConfig | ParticleConfig | see below | Particle effect settings |
TimerConfig
| Option | Type | Default | Description |
|---|---|---|---|
| duration | string | "2:00" | Match duration in "M:SS" format |
| hasBackgroundCircle | boolean | false | Show a circular progress ring around the timer |
| labelColor | string | "#ffffff" | Timer text color |
| labelFontSize | number | 12 | Timer text size in px |
| circleColor | string | "#62cb31" | Countdown ring stroke color |
| circleLineWidth | number | 5 | Countdown ring line width in px |
| circleBackgroundColor | string | "#ff00ff" | Countdown ring background track color |
ColorConfig
| Option | Type | Default | Description |
|---|---|---|---|
| paddle | string | "#ffffff" | Paddle color |
| ball | string | "#ffffff" | Ball color |
| background | string | "#000000" | Canvas background color |
| centerline | string | "#9e9c9c" | Center dividing line color |
| score | string | "#9e9c9c" | Score text color |
AnimatedBackgroundConfig
| Option | Type | Default | Description |
|---|---|---|---|
| starfield | boolean | true | Scrolling starfield effect |
| nebular | boolean | false | Scrolling nebular cloud effect |
Enable at most one background effect at a time.
ParticleConfig
| Option | Type | Default | Description |
|---|---|---|---|
| particlesCount | number | 20 | Number of particles spawned per paddle hit |
Methods
// Control the user paddle programmatically (e.g. on-screen buttons, touch)
game.pressLeft(); // start moving paddle left
game.releaseLeft(); // stop moving paddle left
game.pressRight(); // start moving paddle right
game.releaseRight(); // stop moving paddle right
// Enable or disable sound effects
game.toggleSound(true);
game.toggleSound(false);
// Check if sound is currently enabled
game.hasSound(); // → boolean
// Cycle the ball image (same as pressing B)
game.cycleBallImage();
// Pause or resume the game (same as pressing P or Space)
game.togglePause();
// Adjust AI difficulty manually
game.increaseDifficulty();
game.decreaseDifficulty();
// Resize the canvas when the container changes size
window.addEventListener('resize', () => game.resizeGame());
// Get the internal SoundHelper for advanced audio control
const soundHelper = game.getSoundHelper();
// Tear down the game completely (always call before unmounting)
game.destroy();Paddle control example
Use pressLeft / releaseLeft / pressRight / releaseRight to wire on-screen buttons for touch or mouse control:
function wireButton(btn, pressFn, releaseFn) {
btn.addEventListener('pointerdown', (e) => {
e.preventDefault();
btn.setPointerCapture(e.pointerId);
pressFn();
});
btn.addEventListener('pointerup', releaseFn);
btn.addEventListener('pointercancel', releaseFn);
}
const game = new PongGame(config, '#my-pong');
wireButton(document.getElementById('btn-left'),
() => game.pressLeft(), () => game.releaseLeft());
wireButton(document.getElementById('btn-right'),
() => game.pressRight(), () => game.releaseRight());Keyboard controls
| Key | Action |
| --- | --- |
| ← Arrow Left or A | Move paddle left |
| → Arrow Right or D | Move paddle right |
| P or Space | Pause / resume |
| B | Cycle ball image |
The player controls the bottom paddle. The top paddle is the AI opponent.
Power-ups & power-downs
Every 12 seconds a glowing item appears somewhere in the middle of the field. Hit it with the ball to activate its effect. The item then disappears and a new one spawns 12 seconds later.
How to tell them apart
| Visual | Type | | --- | --- | | Bright solid circle — slow pulsing outer ring | Power-up — helps you | | Dark circle with rotating dashed ring — fast flickering | Power-down — hurts you |
Power-ups (beneficial)
| Letter | Color | Effect | Duration | | --- | --- | --- | --- | | W | Green | Your paddle becomes twice as wide | 6 s | | S | Cyan | Ball speed is reduced by ~55% | 5 s | | F | Orange | The AI paddle is frozen and cannot move | 4 s |
Power-downs (detrimental)
| Letter | Color | Effect | Duration | | --- | --- | --- | --- | | N | Red | Your paddle shrinks to half its normal width | 5 s | | X | Orange-red | Ball speed increases by 80% | 5 s | | C | Purple | The AI paddle becomes twice as wide | 5 s |
A flash message appears on screen when an effect activates (e.g. "COMPUTER FROZEN!" or "NARROW PADDLE!") and fades out over the effect's duration so you always know what is active.
Power-ups and power-downs can be disabled via the in-game settings panel (toggle Power-ups) or through the config option powerUps: false.
Settings panel
The game includes a built-in settings panel (gear icon in the corner of the canvas). Players can change colors, background effects, and particle settings. All changes are saved to localStorage and restored automatically on the next visit.
To reset all saved settings back to defaults:
localStorage.removeItem('chriscreativecode.com-retro-pong-game');Admin mode
isAdmin: true is the recommended starting point when you are setting up the game for the first time. It unlocks the full settings panel so you can configure everything visually before committing to a config object.
const game = new PongGame({ isAdmin: true }, '#game');Workflow:
- Start the game with
isAdmin: true - Open the settings panel (gear icon) — you will see all configurable fields including sizes, speeds, colors, timer, and difficulty
- Adjust the values and watch the changes live
- Scroll to the bottom of the panel and click Copy to copy the full config as JSON
- Paste the JSON into your constructor and remove
isAdmin
In admin mode the settings panel exposes:
- Difficulty fields —
ballSpeed,paddleMoveStep,paddleWidth,paddleHeight,ballDiameter,difficultyLevel - Timer duration —
timer.duration(changing match length affects scoring opportunities) - Config JSON export — a read-only textarea at the bottom of the panel showing the full current config as JSON, with a Copy button
Without isAdmin, these fields are hidden from players so they cannot alter the game balance. Do not ship isAdmin: true to end users.
Resize handling
Automatic (recommended)
Set autoSize: true and autoResize: true together. The game sizes itself to the container on startup and re-sizes automatically whenever the container changes — no extra code needed.
const game = new PongGame(
{ autoSize: true, autoResize: true },
'#game'
);The container must have a CSS-defined size (width and height). For example, to fill the viewport:
#game {
width: 100%;
height: 100vh;
}Manual
When autoResize is false (the default), call resizeGame() yourself whenever the container changes size:
window.addEventListener('resize', () => game.resizeGame());autoSize without autoResize
autoSize: true alone sizes the canvas once at startup. The game then has a fixed size until you call resizeGame() manually.
Troubleshooting
Upgrading from an older version
If you previously had an older version installed on your site and are upgrading to a newer version, you may run into issues caused by stale data in localStorage. To fix this, clear the saved game data in your browser.
Option 1 — Clear all site data (quickest)
- Open the page in Chrome
- Press
F12to open DevTools - Go to Application
- Click Clear site data
Option 2 — Remove only the game's localStorage entry (recommended if you have other data on the page)
- Open DevTools (
F12) and go to Application → Local Storage - Find the key
chriscreativecode.com-retro-pong-game - Select that row and delete it (right-click → Delete, or press the Delete key)
After clearing the entry the game will start fresh with default settings on the next page load.
Browser support
All modern browsers with HTML Canvas support (Chrome, Firefox, Safari, Edge). When importing the ES module build, a bundler (Vite, Webpack, esbuild, Parcel) is recommended for production use.
License
MIT © Chris Schardijn
Changelog
v4.2.0
- New
ballImageconfig option: pass a URL to display a custom image on the ball from game start, clipped to a circle and rotating with the ball - New public methods
cycleBallImage()andtogglePause()for programmatic control - Ball image cycle reduced to three images (face, Star Wars logo, beer)
- All audio and image assets are now bundled inline in the JS file — self-hosted setups no longer need to serve extra files alongside the JS
- Demo: on-screen P (pause) and B (cycle image) buttons added for mobile play; buttons light up on keyboard press as well
v4.1.0
- Performance improvements across rendering components (ball, particles, nebular, starfield, score)
- Fixed a localStorage compatibility bug that could cause stale or incompatible settings to break the game after an upgrade
- Sound effects normalized to equal perceived loudness, silence-cropped, and re-encoded at optimized bitrates
- Unused OGG audio files removed from the package
