A Node.js / HTML5 canvas implementation of Pac-Man, originally by platzh1rsch and modified by Ivan Font. This fork modernizes the server to Node 22 + Express 5, adds a pluggable database layer (in-memory, MongoDB, or PostgreSQL), and ships unit tests, integration tests, and a Docker Compose stack for local development.
Migrating from the previous version? See
MIGRATION.mdfor the full list of changes (breaking and otherwise).
Requires Node.js 22+.
nvm use # picks up .nvmrc
npm install
npm run dev # in-memory DB, no extra services neededOpen http://localhost:8080 to play. High scores live for the lifetime of the
process when DB_TYPE=memory (the default).
Use the Docker Compose stack and pick a profile:
# MongoDB
DB_TYPE=mongo docker compose --profile mongo up --build
# PostgreSQL (a one-shot migration job runs first to create the schema)
DB_TYPE=postgres docker compose --profile postgres up --buildThe app is exposed on http://localhost:8080.
To run the app process locally against a database container, point env vars at it and start the app outside Docker:
export DB_TYPE=postgres POSTGRES_HOST=localhost
npm run db:migrate
npm startSee .env.example for the full set of variables.
| Method | Path | Description |
|---|---|---|
| GET | /healthz |
Liveness (no DB) |
| GET | /readyz |
Readiness (DB healthcheck) |
| GET | /readyz/details |
Readiness plus DB, upstream deps, and config details |
| GET | /version |
Role, version, variant, commit, database, dependency map |
| GET | /metrics |
Prometheus text metrics (requests, DB ops, upstream latency) |
| GET | /config |
Client-visible config (maxLevel, eBee mode, override flags) |
| GET | /config/schema |
Client-visible config fields and env mappings |
| POST | /config |
Validate/authorise a client config override |
| GET | /highscores/list |
Top 10 high scores |
| POST | /highscores |
Submit a high score (validated, see below) |
| GET | /user/id |
Create a new user id (UUID v4) |
| POST | /user/stats |
Update live stats for a user id |
| GET | /user/stats |
List all live stats |
| GET | /location/metadata |
Detect cloud / zone / host |
These mirror the public API with distinct path prefixes for Cilium L7 network policy targeting:
| Method | Path | Description |
|---|---|---|
| GET | /internal/score/read |
List top scores (same as above) |
| POST | /internal/score/write |
Submit a score (same as above) |
| GET | /internal/user/session |
Create a new user id |
| GET | /internal/user/stats |
List all user stats |
| POST | /internal/user/stats |
Update stats for a user id |
| GET | /internal/config/read |
Read server config |
Cloud detection probes (in order): Kubernetes node API, AWS, Azure, GCP,
OpenStack. In Kubernetes, the zone field prefers topology-aware routing
details from Service annotations or EndpointSlice hints when available, then
falls back to the node zone label. All probes have short timeouts; failure
falls through to {cloud: "unknown", zone: "unknown"}.
The server rejects implausible submissions before they reach the database.
The form fields are name, cloud, zone, host, score, level
(application/x-www-form-urlencoded). Validation rules:
| Rule | Response |
|---|---|
score missing or not a number |
400 {"error":"score must be a number"} |
level outside [1, MAX_LEVEL] |
400 {"error":"invalid level"} |
score / level > 2840 (max attainable per level) |
400 {"error":"score is implausible for level"} |
| Otherwise | 200 {"rs":"success", ...} |
The cap 2840 = 104 * 10 + 4 * 50 + 4 * 4 * 100 is the maximum points one
level can yield (104 pills + 4 power pills + four ghosts eaten four times
during beast mode). It is enforced both client-side (in
public/pacman-canvas.js Game.validateScoreWithLevel()) and server-side
so a tampered browser cannot poison the leaderboard.
Examples:
# rejected: score impossible for level 1
curl -i -X POST localhost:8080/highscores \
-d 'name=CHEATER&score=999999&level=1'
# HTTP/1.1 400 Bad Request
# {"error":"score is implausible for level"}
# rejected: level out of range
curl -i -X POST localhost:8080/highscores \
-d 'name=CHEATER&score=100&level=99'
# HTTP/1.1 400 Bad Request
# {"error":"invalid level"}
# accepted: at the per-level ceiling
curl -i -X POST localhost:8080/highscores \
-d 'name=OK&score=2840&level=1'
# HTTP/1.1 200 OK
# {"name":"OK","zone":"...","score":2840,"level":1,"rs":"success"}This is intentionally a small, predictable signature so it can be used as
an example of layered input validation in Kubernetes demos. Pair it with,
for example, a Cilium L7 CiliumNetworkPolicy that allows POST /highscores only from the game pods, a Tetragon TracingPolicy watching
for unexpected exec/file activity in the app container, or an OPA/Kyverno
admission policy on the Deployment. The 400 path is hot enough to be
visible in Hubble flows and pod logs without generating a real attack.
The maximum level is configurable via the MAX_LEVEL environment variable
(positive integer, or the string unlimited, default 10). It is enforced
on POST /highscores and surfaced to the client via GET /config:
MAX_LEVEL=20 npm run dev # cap at level 20
MAX_LEVEL=unlimited npm run dev # no cap (legacy upstream behaviour)In Kubernetes set it as an env var or via a ConfigMap key on the Deployment.
The in-game Settings menu lets players override the cap locally
(stored in localStorage). The override only changes how far the client
will let you progress; the server still validates submissions against
its own MAX_LEVEL, so picking unlimited on the client against a
server capped at 10 will succeed up to level 10 and then start
returning 400 invalid level. That deliberate mismatch is a useful
demo of trusting the server, not the client, for authoritative limits.
By default the Settings page is editable. Set
ALLOW_CLIENT_CONFIG_OVERRIDE=false to lock it:
ALLOW_CLIENT_CONFIG_OVERRIDE=false MAX_LEVEL=10 npm run devWhen locked:
GET /configreturns{"maxLevel":10,"allowClientOverride":false}.POST /configreturns403 {"error":"client gameplay config overrides are disabled on this server"}.- The Settings page shows a notice ("This server does not allow client config changes.") and the maximum-level controls are disabled.
- Any stale override previously saved in
localStorageis ignored at runtime, so the client always matches the server cap.
This is useful for hosted demos and Instruqt-style labs where you want all players to see the same level cap, and it also makes a clean illustration of a server-authoritative config flag pattern.
eBee mode is a visual theme that keeps the maze, collisions, scoring, and server validation unchanged. When enabled:
- Pac-Man is drawn as an 8-bit eBee inspired by the Isovalent eBeeDex.
- eBee's wings flap in place of Pac-Man's mouth animation.
- Ghosts are drawn as chunky 8-bit flowers.
- Flowers turn blue during power-pill mode, preserving the original frightened-state signal.
- Regular pills stay as small white circles; power pills are drawn as Cilium logos.
- Life icons are drawn as Kubernetes logos.
The server default is controlled with EBEE_MODE:
EBEE_MODE=true npm run devBy default players can override that theme locally from the Settings page,
with the preference stored in localStorage as pacman.ebeeMode. To hard
set eBee mode at the server level, disable the client override:
EBEE_MODE=true ALLOW_CLIENT_EBEE_MODE_OVERRIDE=false npm run devWhen locked, GET /config advertises
{"ebeeMode":true,"allowEbeeModeOverride":false}, POST /config returns
403 for attempted eBee-mode changes, and the Settings checkbox is shown
read-only.
Example ConfigMap and patch files live under k8s/examples:
configmap.yamlsetsMAX_LEVEL,ALLOW_CLIENT_CONFIG_OVERRIDE,EBEE_MODE,ALLOW_CLIENT_EBEE_MODE_OVERRIDE,APP_VERSION,APP_VARIANT,APP_COLOR, andDEMO_SECURITY_MODE.deployment-env-patch.yamlwires those values into a Deployment viaenvFrom.multi-role-deployment.yamldeployspacman-web,pacman-score, andpacman-useras separate Deployments and Services, with the web role forwarding east-west traffic to the score and user backends.cilium-l7-policy.yamlapplies Cilium L7CiliumNetworkPolicyrules that restrict which HTTP methods and paths each role can reach, including browser static assets and/internal/*policy-friendly service paths.prometheus-servicemonitor.yamlcreates a PrometheusServiceMonitorto scrape the/metricsendpoint on all Pac-Man roles.tetragon-tracingpolicy.yamladds TetragonTracingPolicyresources for file-access and process-exec tracing, triggered by the demo incident probe endpoints.kustomization.yamlapplies the base ConfigMap and multi-role Deployment without requiring optional CRDs.overlays/cilium,overlays/prometheus, andoverlays/tetragonapply optional CRD-backed resources only when those controllers are installed.helm-values.yamlshows a Helm-style values mapping for charts with anextraEnvpattern.ebee-mode-rollout-patch.yamlflips eBee mode on and bumps a pod-template annotation to force a rollout.
The app can run as separate Kubernetes roles from one image for Cilium demos:
APP_ROLE=web|score|user|config|topology|incident|grpcWhen SCORE_SERVICE_URL or USER_SERVICE_URL is set on the web role, the
browser-facing routes stay the same, but the web pod forwards score and user
traffic to those internal Services through /internal/* paths. This creates
real east-west traffic for Hubble and CiliumNetworkPolicy demos while
preserving local in-process behavior when the URLs are unset.
GET /version returns visible canary metadata from APP_VERSION,
APP_VARIANT, APP_COLOR, COMMIT_SHA, and BUILD_DATE. The same metadata is
also included in GET /config so the Settings panel can show which backend
variant handled the request.
Lab-only fault and incident hooks are double gated. They require both:
DEMO_SECURITY_MODE=true
DEMO_TOKEN_HEADER=x-demo-token
DEMO_TOKEN=pacman-demoWith the correct token header, demos can inject controlled HTTP delay/status
responses using x-demo-fault-delay-ms and x-demo-fault-status, record a
structured incident with POST /demo/incidents, or trigger safe Tetragon probes
with POST /demo/incidents/file-probe and POST /demo/incidents/exec-probe.
With demo mode disabled, those incident actions return 403 and normal gameplay
is unaffected.
The app image includes a dependency-free simulator that creates realistic
frontend-style sessions. It fetches the browser shell, reads /config and
/location, creates user IDs, posts user stats during simulated gameplay,
refreshes highscores, and occasionally submits plausible scores.
Run it against a local or port-forwarded app:
PACMAN_BASE_URL=http://127.0.0.1:8080 USERS=25 DURATION_SECONDS=300 npm run simulate:usersUseful knobs:
USERS=100
DURATION_SECONDS=0
MIN_THINK_MS=750
MAX_THINK_MS=3500
MIN_SESSION_SECONDS=45
MAX_SESSION_SECONDS=180
SCORE_SUBMIT_RATE=0.08
HIGH_SCORE_READ_RATE=0.35
REQUEST_TIMEOUT_MS=4000DURATION_SECONDS=0 runs continuously, which is useful when the simulator is
deployed in Kubernetes while you scale, roll, fault, or policy-restrict the app.
npm test # unit + route tests (memory adapter)
RUN_ADAPTER_TESTS=1 npm run test:adapters # mongo + postgres via testcontainers
npm run lintThe canMoveTo wall-rules helper in src/client/movement.js is unit-tested
in test/movement.test.js and pins the wall/teleport bug fixes documented in
MIGRATION.md.
src/
app.js Express 5 app factory
server.js Boots adapter + http server, graceful shutdown
config/index.js dotenv + zod validation
logger.js pino logger
metrics.js Lightweight Prometheus metrics (no deps)
db/
adapter.js DatabaseAdapter interface
index.js Factory: DB_TYPE -> adapter
memory.js In-memory adapter (default; powers tests and dev)
mongo.js MongoDB adapter (driver v6)
postgres.js PostgreSQL adapter (pg)
migrate.js Forward-only migration runner (postgres)
migrations/postgres/0001_init.sql
routes/
health.js /healthz, /readyz (with upstream dep probes)
highscores.js /highscores
user.js /user/id, /user/stats
location.js /location/metadata
metrics.js /metrics (Prometheus text format)
internal.js /internal/* policy-friendly endpoints
client/
movement.js Pure wall/wrap helpers (used by tests)
public/ Static game assets (served as-is)
views/ Pug error template
test/ Tests
k8s/examples/ Kubernetes, Cilium, Tetragon, and Prometheus manifests
docker/Dockerfile Multi-stage, node:22-alpine, non-root, HEALTHCHECK
docker-compose.yml memory / mongo / postgres profiles
There are two deployment surfaces:
- In-repo learning examples at
k8s/examples/. A small, self-contained set of manifests (multi-roleDeployments, aConfigMap, Cilium L7 example, a PostgreSQL example with a migrationJob, optional Prometheus and Tetragon overlays) suitable for reading, hacking on locally, and trying out Cilium/Hubble/Tetragon flows. - Extended deployment build-out at
saintdle/pacman-for-k8s. A richer surface that layers in persistent storage, Pod Security Standards, install/uninstall scripts, and a catalogue of Cilium demos (east-west, cluster mesh, canary, fault injection, Tetragon enforcement, gRPCRoute, topology-aware routing, runtime security). Use this when you want a production-leaning reference rather than a minimal example.
Both surfaces target the same docker.io/saintdle/pacman image built from
this repository.
GPL-3.0-or-later. See LICENSE.