feat(js): m11 pong capstone (model, rules, view, app, page) and polling smoke test

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-28 23:59:25 +01:00
co-authored by Claude Sonnet 5.5
parent fd7c5f2acf
commit 1d1d918805
16 changed files with 3090 additions and 3 deletions
+31
View File
@@ -0,0 +1,31 @@
// m11 pong app: the same wiring as app.js (streams in, drawings out), reusing the course's modules.
import { initialPong, PongAction } from './model.js'
import { pongUpdate } from './rules.js'
import { pongView } from './view.js'
import { map as mapIO, randomSeed, drawScene } from '../io.js'
import { keyDowns, keyUps, interval, map, filterMap, merge, scan, startWith } from '../stream.js'
export const FRAME_MS_PONG = 30
const keys = {
ArrowUp: 'Up', w: 'Up',
ArrowDown: 'Down', s: 'Down',
}
// hasOwn, not `key in keys`: "constructor" and "toString" are inherited, not keys we mapped.
export const pongKey = (isDown, key) =>
Object.hasOwn(keys, key) ? (isDown ? PongAction.Press : PongAction.Release)(keys[key])
: key === 'r' && isDown ? PongAction.Restart
: null
export const pongActions = merge(
map(interval(FRAME_MS_PONG), () => PongAction.Frame),
merge(
filterMap(keyDowns, (key) => pongKey(true, key)),
filterMap(keyUps, (key) => pongKey(false, key))))
export const pongStates = (seed, actions) =>
startWith(scan(actions, pongUpdate, initialPong(seed)), initialPong(seed))
export const pongMain = mapIO(randomSeed, (seed) =>
map(pongStates(seed, pongActions), (state) => drawScene(pongView(state))))
+5
View File
@@ -0,0 +1,5 @@
import { runApp } from '../runtime.js'
import { browserPlatform } from '../browser.js'
import { pongMain } from './app.js'
runApp(pongMain, browserPlatform(document.getElementById('game'), window))
+38
View File
@@ -0,0 +1,38 @@
// m11 pong model: the same ideas as the snake model, for a different game.
/** @typedef {{ball: {x: number, y: number}, vel: {dx: number, dy: number}, player: number, cpu: number,
* held: {up: boolean, down: boolean}, score: {player: number, cpu: number},
* status: 'Playing'|'Over', seed: number, frame: number}} PongState */
// The court, in cells. `player` and `cpu` are the y of the top cell of each paddle.
export const W = 80
export const H = 50
/** Pixels per cell. */
export const PCELL = 8
/** Paddle height in cells. */
export const PADDLE = 8
/** Column of the player's paddle (left) and the cpu's paddle (right). */
export const PLAYER_X = 2
export const CPU_X = 77
/** Points needed to win the match. */
export const WIN = 5
export const initialPong = (seed) => ({
ball: { x: 40, y: 25 },
vel: { dx: 1, dy: 1 },
player: 21,
cpu: 21,
held: { up: false, down: false },
score: { player: 0, cpu: 0 },
status: 'Playing',
seed,
frame: 0,
})
/** Everything that can happen in a match, as data. */
export const PongAction = {
Press: (key) => ({ tag: 'Press', key }),
Release: (key) => ({ tag: 'Release', key }),
Frame: { tag: 'Frame' },
Restart: { tag: 'Restart' },
}
+67
View File
@@ -0,0 +1,67 @@
// m11 pong rules: one pure `pongUpdate`, built from small steps that each return a new piece of state.
import { H, W, PADDLE, PLAYER_X, CPU_X, WIN, initialPong } from './model.js'
import { nextUint32 } from '../random.js'
export const clamp = (lo, hi, x) => Math.min(hi, Math.max(lo, x))
const clampPaddle = (y) => clamp(0, H - PADDLE, y)
const movePlayer = (state) =>
clampPaddle(state.player + (state.held.down ? 1 : 0) - (state.held.up ? 1 : 0))
// The cpu chases the middle of its paddle toward the ball, one cell every other frame.
const moveCpu = (state, frame) =>
frame % 2 === 0 ? clampPaddle(state.cpu + Math.sign(state.ball.y - PADDLE / 2 - state.cpu)) : state.cpu
const hits = (paddle, y) => paddle <= y && y < paddle + PADDLE
// Where the ball goes next, given the paddles' new positions. `scorer` says who got the point, if anyone.
const moveBall = (state, player, cpu) => {
const { ball, vel } = state
const wall = ball.y + vel.dy < 0 || ball.y + vel.dy >= H
const dy = wall ? -vel.dy : vel.dy
const ny = ball.y + dy
const nx = ball.x + vel.dx
const bounce =
(nx === PLAYER_X && vel.dx < 0 && hits(player, ny)) ||
(nx === CPU_X && vel.dx > 0 && hits(cpu, ny))
return {
ball: { x: bounce ? ball.x : nx, y: ny },
vel: { dx: bounce ? -vel.dx : vel.dx, dy },
scorer: nx < 0 ? 'cpu' : nx >= W ? 'player' : null,
}
}
// After a point the ball restarts in the middle, heading toward whoever conceded; the seed picks up or down.
const serve = (state, scorer) => {
const [r, seed] = nextUint32(state.seed)
const score = { ...state.score, [scorer]: state.score[scorer] + 1 }
return {
...state,
ball: { x: 40, y: 25 },
vel: { dx: scorer === 'cpu' ? -1 : 1, dy: r % 2 === 0 ? 1 : -1 },
score,
seed,
status: score[scorer] >= WIN ? 'Over' : 'Playing',
}
}
const frame = (state) => {
if (state.status !== 'Playing') return state
const n = state.frame + 1
const player = movePlayer(state)
const cpu = moveCpu(state, n)
const { scorer, ...moved } = moveBall(state, player, cpu)
const next = { ...state, ...moved, player, cpu, frame: n }
return scorer === null ? next : serve(next, scorer)
}
export const pongUpdate = (state, action) => {
switch (action.tag) {
case 'Press': return { ...state, held: { ...state.held, [action.key.toLowerCase()]: true } }
case 'Release': return { ...state, held: { ...state.held, [action.key.toLowerCase()]: false } }
case 'Restart': return initialPong(state.seed)
case 'Frame': return frame(state)
default: return state
}
}
+24
View File
@@ -0,0 +1,24 @@
// m11 pong view: the same Scene shapes as the snake view, so the same drawScene and runtime draw it.
import { PCELL, PADDLE, PLAYER_X, CPU_X } from './model.js'
const rect = (x, y, w, h, color) => ({ tag: 'Rect', x, y, w, h, color })
const text = (x, y, str, size) => ({ tag: 'Text', x, y, text: str, color: '#ffffff', size, align: 'center' })
const paddle = (column, y) => rect(column * PCELL, y * PCELL, PCELL, PADDLE * PCELL, '#ffffff')
const overlay = (state) =>
state.status === 'Over'
? [
text(320, 200, state.score.player > state.score.cpu ? 'You Win!' : 'CPU Wins', 32),
text(320, 230, 'Press R to restart', 16),
]
: []
export const pongView = (state) => [
rect(0, 0, 640, 400, '#111111'),
paddle(PLAYER_X, state.player),
paddle(CPU_X, state.cpu),
rect(state.ball.x * PCELL, state.ball.y * PCELL, PCELL, PCELL, '#ffeb3b'),
text(320, 30, `${state.score.player} ${state.score.cpu}`, 24),
...overlay(state),
]