Pong in TypeScript · Part 1 of 2 in the series
Part 1: Canvas, Game Loop and Paddles
Set up a canvas with Vite and TypeScript, write a frame-rate independent game loop and move two paddles with the keyboard.

Quest objectives
- Create a TypeScript project with Vite
- Draw on an HTML canvas
- Write a game loop with requestAnimationFrame and delta time
- Read keyboard input without any lag
Pong is the perfect first game: it fits into a single file, yet it contains the same building blocks as much bigger games. There is a loop, input, movement, collision and a score. In this part you lay the foundation and get both paddles moving.

Create the project
You need Node.js installed. Vite sets up a TypeScript project in seconds:
npm create vite@latest pong -- --template vanilla-ts
cd pong
npm install
npm run devDelete everything in src/ except for main.ts, empty that file, and replace index.html with a page that only holds a canvas:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Pong</title>
<style>
body {
margin: 0;
height: 100vh;
display: grid;
place-items: center;
background: #1a1a2e;
}
</style>
</head>
<body>
<canvas id="game" width="800" height="500"></canvas>
<script type="module" src="/src/main.ts"></script>
</body>
</html>The game loop
A game is a loop: it reads input, updates the world and draws the result, over and over. In the browser, requestAnimationFrame calls you right before the next repaint, usually 60 times per second.
"Usually" is the important word. Some screens run at 144 Hz and a busy laptop might drop to 30. If paddles moved a fixed distance per frame, the game would run at a different speed on every machine. That's why everything moves in pixels per second, multiplied by dt: the seconds since the last frame.
Keyboard input
Keydown events alone are not enough: they fire once, pause, and then repeat at the operating system's rate. So you keep a set of the keys that are currently held and check it in every frame. event.code names the physical key, so W and S stay in the same spot on every keyboard layout.
Here is the complete main.ts for this part:
const canvas = document.querySelector<HTMLCanvasElement>('#game')!;
const ctx = canvas.getContext('2d')!;
const PADDLE_WIDTH = 12;
const PADDLE_HEIGHT = 80;
const PADDLE_SPEED = 400; // pixels per second
type Paddle = { x: number; y: number };
const left: Paddle = { x: 24, y: canvas.height / 2 - PADDLE_HEIGHT / 2 };
const right: Paddle = { x: canvas.width - 24 - PADDLE_WIDTH, y: left.y };
const keys = new Set<string>();
window.addEventListener('keydown', (event) => keys.add(event.code));
window.addEventListener('keyup', (event) => keys.delete(event.code));
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function movePaddle(paddle: Paddle, up: string, down: string, dt: number): void {
if (keys.has(up)) paddle.y -= PADDLE_SPEED * dt;
if (keys.has(down)) paddle.y += PADDLE_SPEED * dt;
paddle.y = clamp(paddle.y, 0, canvas.height - PADDLE_HEIGHT);
}
function update(dt: number): void {
movePaddle(left, 'KeyW', 'KeyS', dt);
movePaddle(right, 'ArrowUp', 'ArrowDown', dt);
}
function draw(): void {
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ffffff';
for (const paddle of [left, right]) {
ctx.fillRect(paddle.x, paddle.y, PADDLE_WIDTH, PADDLE_HEIGHT);
}
}
let last = performance.now();
function frame(now: number): void {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
update(dt);
draw();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);Open the page and move the left paddle with W and S and the right one with the arrow keys. Both stop at the edges thanks to clamp(). In the next part you add the ball and turn this into an actual game.