Pilot a craft through the gravity wells of real black holes. The cursor is where you want to be; the field decides how much say you get.
▶ Play it live: wesleyarcade.com/singularity
Originally written in 2019 in p5.js. Rebuilt in 2026 on the Arcade Graphics Engine with a real gravitational model — same premise, nothing else shared.
The game does not invent a gravity number. A level authors a black hole by one value, its mass in solar masses, and every other property is derived from it using the actual constants:
| Quantity | Formula | 10 M☉ |
|---|---|---|
| Schwarzschild radius | r_s = 2GM/c² |
29.5 km |
| Photon sphere | 1.5 r_s |
where light orbits |
| Innermost stable circular orbit | 3 r_s |
no stable orbit inside |
Gravity uses the Paczyński–Wiita potential, the standard pseudo-Newtonian approximation from accretion astrophysics:
Φ(r) = −GM / (r − r_s) a(r) = GM / (r − r_s)²
It costs one subtraction over Newton and buys the three things that make a black
hole a black hole: the potential diverges at the horizon, so it is a true point
of no return rather than somewhere a big enough engine can escape; it reproduces
the ISCO at exactly 3 r_s; and it puts the marginally bound orbit at 4 r_s,
as in the exact solution. Far away, r_s becomes negligible and it relaxes into
Newtonian 1/r².
Paczyński, B. & Wiita, P. J. (1980), Thick accretion disks and supercritical luminosities, Astronomy & Astrophysics 88, 23.
Two consequences worth knowing before you fly:
- Craft mass does not affect how you fall. Gravitational acceleration is
independent of the mass being accelerated, so every hull follows the same arc.
Mass divides thrust instead (
a = F/m), so a heavy craft shares the light one's fate but has less authority to argue with it. - Small black holes are far more dangerous than large ones. Surface gravity
goes as
1/r_s, so a 3 M☉ hole 12 px across has a fiercer well than a 22 M☉ one at 88 px. The big ones are terrain; the small ones are ambushes.
Nothing marks where the pull becomes unwinnable, and nothing measures it either. There used to be a ring at the craft-specific escape radius drawn in red as you neared it, and a GRAV bar in the corner reading the field strength. Both were the wrong kind of help: one turned a thing the player is meant to develop a feel for into a boundary they could read off the screen, the other into a number.
The HUD is down to two things: a row of the ship you are flying in the bottom left, the way an arcade cabinet has always shown lives, and the capture banner. What is left has to be interpreted. The aura falls off with the field, and the craft starts going soft on the cursor as thrust loses to the pull — that last one is the real teacher, since you feel the steering give before anything else tells you. The only explicit signal is the capture banner, and it fires once you are already past saving, so it narrates rather than warns.
Slingshots off a horizon could reach 18,000 px/s, which crosses the board in
three frames. Speed is now capped at 1,500, enforced the way relativity does it:
acceleration along the direction of travel is damped by (1 - v²/c²)^(3/2)
while acceleration across it is untouched, so a body approaches the limit
asymptotically instead of hitting a clamp. A hole can still whip a rock through
a hairpin at full speed; it just cannot keep adding speed.
That limit is the real c, converted — not a number picked to feel right. It
used to be a house rule: at the original time scale c worked out at 379 px per
game-second while the craft flew at 470–610, so the game was superluminal in its
own units. Lengthening the game-second to strengthen the field moved c with it
(gravity scales as T², c as T), and it now lands at 928 px/s — well above
every craft's top speed (0.51c–0.66c). Nothing on the board outruns light.
Horizon radius is r_s / METERS_PER_PIXEL and has no time term; gravitational
acceleration carries a T². So SECONDS_PER_GAME_SECOND is the one dial that
changes how hard the wells pull while every hole stays exactly the size it was.
It has been raised by √6 from the original scale, so the field is six times
what it started at — a 22 M☉ hole's escape limit went from 137px to 209px
with its horizon unchanged at 88px.
A third obstacle: same gravity law and the same mu as a black hole of equal
mass, because gravity does not care what the mass is made of. What differs is
that a star has a surface, so the field never gets the room to climb — you are
stopped at the photosphere, where 1/r² is still mild. A 22 M☉ black hole lets
you to within 88px of its centre; a 14 M☉ red giant stops you at 61px, where the
pull is about two thousand times gentler.
There is no horizon term in a star's gravity. Paczyński–Wiita corrects Newton near an event horizon and a star has none, so the plain inverse square is the right law there.
Stellar density falls out for free: a 2.4 M☉ white dwarf at 16px is fiercer at its surface than a 14 M☉ red giant at 61px, despite carrying a sixth of the mass. Small and dense beats large and diffuse, exactly as it does in the sky.
One honest compromise: stellar radii are not to scale and cannot be. The length scale is pinned by black hole horizons at 4px per solar mass, which puts the Sun's photosphere around 940,000px across. A board cannot show a 30km horizon and a 700,000km surface at once, so a star's radius is authored. Its mass — and therefore its pull — stays real.
Asteroids can orbit, and some complete several revolutions. What they cannot do
is fall into orbit: a two-body gravitational encounter conserves specific
orbital energy, so anything arriving unbound leaves unbound. Capture needs a
third body or dissipation. About 41% of rocks spawn bound, and a level may seed
a few directly onto near-circular orbits around a hole that sits well inside the
board — asteroids.orbiters in the level JSON. Those are placed on an orbit,
not captured into one; everything after the placement is the same integration as
the rest of the field, free to precess, decay or be flung out.
A life opens on a clear board. Every rock is re-entered from off-screen at the moment play begins, so the first wave arrives together rather than the player inheriting whatever the last life left mid-flight. Because nothing spawns on-screen, the wave takes about a second to fly in — that pause is the point, not a side effect: it is the beat where a pilot picks a line before anything is in the way. The field is also emptied while a wreck is playing out.
Asteroids enter only from above the top edge or below the bottom edge, always
off-screen. Nothing appears inside the board, and nothing arrives from the right,
where the gate is -- a rock entering there closes on a player who is looking the
other way at the moment they have committed to the run. npm run spawns enforces
both rules.
npm run orbits audits all of this: how many spawn bound, why rocks get
recycled, and how far round a hole they get first.
Fixed 120 Hz timestep with an accumulator, integrated by semi-implicit (symplectic) Euler — velocity first, then position from the new velocity. Explicit Euler pumps energy into an orbit and makes it spiral outward artificially; the symplectic form conserves it well enough to hold a clean arc. The renderer interpolates between steps, so motion is smooth on any display and the simulation runs identically at 60 Hz and 144 Hz.
The field is black and a black hole is an absence: a disc of true black with a white aura hugging the rim to say where the edge is, and nothing else. All of that geometry is static for a given mass, so each hole renders once into an offscreen canvas and is blitted. Glow is expensive and therefore rationed -- roughly 27 shadow-blurred draws per frame out of ~900 canvas calls total.
Subtext is set in Share Tech Mono rather than the kit's Rajdhani body role: a
game running real Schwarzschild geometry should speak in a readout voice, and
sentence-case Rajdhani is the one thing in the type kit that reads as generic UI.
Canvas text does not trigger webfont loading the way DOM text does -- setting
ctx.font to a face the browser has not already fetched falls back silently,
with nothing to correct it later. Every weight the game draws with is requested
explicitly and awaited before the first frame.
The starfield follows a power law: most stars sit near the threshold of visibility and a few carry the field, which is most of what makes a scatter of dots read as sky. Colour is white with only a whisper of warm or cool on a minority — real stellar colours are far less saturated than they are usually drawn. Each star twinkles on its own phase, faint ones shimmering more than bright ones, and the brightest carry a faint diffraction cross. Positions round to whole pixels at draw time, because a 1px dot on a half-pixel boundary antialiases across two columns and turns to mush.
Colour belongs to the craft. The four hulls keep their 2019 shapes and their 2019 palette exactly; only the rendering is new.
Asteroid tails are drawn from a recorded position history rather than extrapolated along the velocity vector, so a rock whipping past a hole trails a curve that matches the path it actually flew. Sampling at a fixed interval also makes the tail's length proportional to speed for free.
Rocks are drawn in red only, ramped by speed from deep crimson at rest to hot ember at the board's limit. One hue varying continuously reads as a temperature map; two hues would read as categories.
Each rock is built as two rings -- an outer hull and an inner ring pulled toward the middle -- triangulated into a rim band around a raised cap. That shoulder is what gives a 15px shape the read of a solid with volume rather than a flat plate with a gradient on it.
An earlier pass drew each disk as 9 bands of 46 blurred arc segments, which came to 1,656 shadowed strokes a frame and ran at 5 fps. It was also simply worse to look at. The reference is the Jupiter sequence in 2001: a few monumental shapes on near-total black, hard edges, no texture standing in for composition.
Gravity treats all four identically — acceleration does not depend on the mass being accelerated. What differs is three axes, and each hull is strong on some and pays for it on the others:
| Craft | hull | acceleration | top speed | fraction of c | 0→top |
|---|---|---|---|---|---|
| Superbug | 10.0 | 3880 | 852 | 0.92c | 0.22s |
| Psych Bike | 7.9 | 3710 | 882 | 0.95c | 0.24s |
| The Compiler | 8.3 | 3943 | 788 | 0.85c | 0.20s |
| Voidwalker | 10.5 | 5077 | 776 | 0.84c | 0.15s |
Top speeds now run at 0.84c–0.95c, and that ceiling is real rather than chosen.
integrate holds everything to c, so a hull asking for more than 928 px/s
simply pins there — and if two hulls both ask for more, their top speeds become
identical and the whole axis collapses. c cannot be raised independently
either: horizon size fixes METERS_PER_PIXEL, field strength fixes
SECONDS_PER_GAME_SECOND, and c = c_SI · T / MPP falls out of the two. The
only way past this wall is to strengthen the field, since c scales as T while
gravity scales as T² — 30% more speed would cost 69% more gravity.
Every hull draws at the same overall size — 2.7% spread, checked by
npm run metrics. The hitbox difference comes entirely from how much of that
size each silhouette actually fills, measured by rasterising the art and counting
lit pixels: the Voidwalker's plate cluster fills 78% of its box, the
Compiler's thin swept wings only 43%. Two craft the same size on screen, one
a third easier to hit.
That measurement has to be a rasteriser. An earlier Node-side version tracked path coordinates through a stub context and was wrong in a way that mattered — a Bézier's control points sit well outside the curve they describe, so the curve-heavy Compiler measured 63px against a true 35.
Top speed and acceleration are separate traits, and the roster is built on the difference. The Psych Bike has the highest ceiling and the weakest engine, so it takes 0.32s to get there; the Compiler reaches its lower ceiling in 0.12s. One wins an open sprint, the other wins anywhere that demands changing direction in a hurry. Collapsing both into a single "speed" number would hide the whole trade.
Mass is a fourth axis, and not a restatement of acceleration. Gravitational acceleration does not depend on the mass being accelerated, so a heavy hull does not fall faster — the equivalence principle is load-bearing here. What mass does is govern how well velocity sticks: drag is a force, and a force slows a heavy body less, so once a well has given a heavy craft speed it sheds that speed slowly. The pull follows it out. That is independent of engine authority — the Compiler is heavy and powerful, the Psych Bike light and weak — so knowing one tells you nothing about the other.
Ranges are deliberately asymmetric: top speed spans 13% across the roster while acceleration spans 149%. In a reach-the-gate game top speed is the stronger lever — less time on the board is less exposure — so a wide spread there overwhelms everything else. The measured proof is in the matrix: at a 470–700 speed spread the averages diverged by 11.3 points and two hulls never won anything.
hull is the craft's extent in design pixels, measured from the art with
npm run bounds rather than picked. It sets wall clearance, and asteroids strike
within half of it — these silhouettes are open frames rather than solid discs, so
a full-extent hitbox would punish near-misses through gaps you can see straight
through.
Tuning the roster against level 1 overfits it: there, holes outkill debris about
three to one, so hitbox barely registers and whichever hull dodges best simply
wins. npm run balance runs the roster across five archetypes instead — a
channel gauntlet, a sixty-rock debris storm, a three-body system, an open sprint,
and a warren of small fierce wells.
The target is not equal survival. It is that no hull leads the average and every hull leads somewhere: currently 1.5 points of spread across the four averages, with all four topping at least one archetype. A roster where the right answer depends on the level is a roster worth choosing from.
One thing that harness needs to be honest: its autopilot has to attempt escapes. A pilot that only flies waypoints never converts thrust into survival, so the matrix just ranks hitboxes and no high-thrust hull can ever win. When the field starts beating the engine it now turns radially outward — which is what a human does, and without it the measurement is not of the game people play.
levels/manifest.json sets the order and the intended length of the campaign:
{ "target": 15, "order": ["level1"] }An explicit manifest rather than scanning for level1..levelN — the order is a
design decision, and a run of files on disk cannot express "this one comes third"
without renaming everything after it. target is the campaign's intended length,
so the clear card can say 1 of 15 before all fifteen exist.
Lives and craft choice carry across levels; only restart() puts you back at the
beginning. Clearing the last level in order ends the run rather than trying to
load the next one.
Adding a level is two steps: drop the JSON in levels/, add its id to order.
The five archetypes in npm run balance are already valid level specs.
Any attractor can be put in orbit around any other. A level authors the shape of the orbit and nothing else:
{
"kind": "red-giant", "solarMasses": 1.8, "radius": 0.069,
"orbit": {
"host": 0, // index into blackHoles ++ stars, in file order
"apoapsis": 0.424, // x DESIGN_HEIGHT -- furthest from the host
"periapsis": 0.278, // x DESIGN_HEIGHT -- closest approach
"argument": 0.87, // which way the long axis points, in turns
"direction": -1, // +1 anticlockwise, -1 clockwise
"lead": 2.4 // seconds to run forward before the level opens
}
}There is no period, because a period is not a free parameter. apoapsisSpeed()
solves the two conserved quantities of the Paczynski-Wiita potential for the one
velocity that closes an ellipse between those radii, and how long a lap takes
then falls out of Kepler's third law — which is why the inner bodies on level 1
are visibly quicker than the outer ones without anything saying so.
Seeded, the body is integrated, not animated. It falls through its host's field on the same symplectic step as the asteroids and the craft, so the ellipse is a consequence of the field rather than a curve traced by a parameter. Three things follow from that, and all three took finding:
- The orbits precess. Only an exact inverse square closes an orbit, and
Paczynski-Wiita is not one, so the long axis turns a few degrees every pass —
the pseudo-Newtonian stand-in for the relativistic perihelion advance. It is
real, not integrator error: hold the step and it converges (2.897 deg/lap at
1/120, 2.972 at 1/30720), and the same orbit with the horizon term removed
precesses by 0.000. For level 1 it runs 34–135 deg/lap, which the weak-field
formula
6πGM/c²a(1−e²)predicts to within about 10%. - The board constraint is the annulus, not the snapshot. Because the axis
reaches every orientation eventually, a body can occupy anywhere between its
apsides, and an orbit fits only if
apoapsis + radiusclears the nearest edge. A level composed against a snapshot looks right at load and puts a star off the top edge ten minutes later.npm run starschecks this. - No speed limit on the orbit step. These orbits are genuinely relativistic
— a body at seven Schwarzschild radii does about a third of light speed — but
Paczynski-Wiita is the relativistic correction, so
integrate()'s longitudinal damping on top counts the same physics twice. It also only fires on the infalling half of each lap, so it bleeds energy, and the four stars quietly fell into the hole over the first three minutes.
An orbiting body feels its host and nothing else. Four mutually attracting stars is a five-body problem and five-body problems are chaotic — the level would not be the same level twice. Everything else on the board feels all of them in full.
lead is how a level starts bodies at different points on their orbits. Rather
than solve Kepler's equation for an arbitrary anomaly — an approximation anyway,
since these orbits are not closed — the orbit is simply run forward. Start the
clock early and let the physics put the body where it belongs.
A hole is drawn at its shadow, not its horizon. Light passing within the
photon capture radius b = 3*sqrt(3)GM/c^2 spirals in and never comes back, so
the dark disc an observer sees is that radius -- 3*sqrt(3)/2 = 2.598 r_s. A
black hole looks about two and a half times larger than its own horizon because
it bends light around itself and presents a magnified image of itself. This is
the number in the Event Horizon Telescope pictures: M87's shadow is 2.6 r_s
across, not 1. Drawing the bare horizon, as this used to, is the more familiar
picture and the wrong one; switching made every hole 2.6x bigger on screen
without touching its mass or its pull by one part.
It still kills at the horizon, though, which is not an inconsistency. The
shadow is the hole's image, not a surface: a craft at 2 r_s is between you and
the hole, lit, and alive -- drawn in front of the disc rather than inside
anything. Killing at the disc would also fail at mass, since the shadow grows as
M while escapeLimit() grows more slowly and the two cross near 7 solar
masses, above which a craft could power out of the black disc. In practice the
gap costs nothing: escapeLimit() sits outside the shadow at level 1's mass, so
touching the disc means having already lost. node tools/smoke.mjs reports it.
Whether a rock swings past a hole or goes straight down it is decided by one
number, its angular momentum L = v x r about that hole -- and not at all by
its mass, which cancels out of the equation of motion. Below a critical L the
effective potential
f(r) = L^2 / 2r^2 - mu / (r - r_s)
has no local maximum, so there is no periapsis and no way past: the rock spirals
in whatever its speed or angle. Setting df/dr = 0 and minimising puts the
marginal case at r = 3 r_s, the ISCO exactly, so L_crit is the angular
momentum of a circular orbit there and scales with the hole's mass.
With purely random lateral drift, 25 of level 1's 28 rocks sat under L_crit
from the instant they spawned, and the board really was a vacuum. The fix is not
to slow them or lighten them: it is asteroids.swirl, a lateral bias in px/s
that makes debris from above drift one way and debris from below the other, so
the field carries net circulation. That is what every real debris disc does, and
why discs are discs rather than shells -- infalling material carries angular
momentum it cannot shed, so it settles into rotation instead of raining straight
in. With swirl and a lighter hole, 10 of 28 start doomed instead of 25, and
rocks complete 88 full revolutions in four minutes instead of 8.
"backdrop": "assets/backdrop.jpg" puts one stationary image behind the board,
cover-fit; omit it and the field is black. It is deliberately not a parallax
starfield. A procedural scatter of dots never read as sky — a real night sky
spans orders of magnitude of brightness, almost all of it below what one 8-bit
pixel can hold, so it quantises into either sparse specks or grey dust — and
drifting it put slow parallax behind a board whose whole subject is things
falling. A captured sky carries the nebulosity and dust lanes that dots cannot.
The image must be at least as wide as the canvas it will be drawn on to stay sharp: roughly 2560px for a 1440p display, 3840px for 4K.
Note that a lit backdrop is unforgiving of translucency bugs. The black hole's
horizon had been 24.7% white since it was written — destination-out scales
what it erases by the source alpha, and the fill style still in effect was the
aura gradient — which is invisible over black and a grey coin over a starfield.
The board is authored at 1280x720 and letterboxed, so in portrait it collapses to an unplayable strip. Rather than pretend, the game stops and asks for a rotation. Coming back to landscape does not resume on its own — a device that has just been turned is usually still moving, and dropping a player into a live board mid-rotation costs them a craft they never saw coming. They tap when ready.
Enter pauses and resumes on desktop. A portrait pause overrides a manual one, so the rotate prompt is always what the player sees.
npm start # http://localhost:3210 — zero dependencies
npm run physics # geometry table for a range of black hole masses
npm run simulate # autopilot balance report for levels/level1.json
npm run speeds # asteroid speed distribution, for tuning the tails
npm run stars # star orbits: apsides, lap time, precession, envelope
npm run paths # renders the star orbits to a PNG, to look at
npm run capture # how often asteroids actually orbit the stars
node tools/smoke.mjs # headless wiring + physics assertions
npm run vendor:engine # re-copy the graphics engine from the sibling checkouttools/simulate.mjs flies the real Game class — same physics, same
integrator, same collision rules — under an autopilot sweeping hundreds of
routes. It reports the share that survive per craft, which is what catches a
level that is unfair, trivial, or completable by only one hull.
public/
index.html styles.css favicon.svg
src/
core/ viewport (letterboxing), fixed-timestep loop, input, PRNG
game/ constants, physics, entities, crafts, level, game state
render/ theme, starfield, black holes, asteroids, craft, HUD, screens
vendor/arcade-graphics-engine/ vendored ESM build, see npm run vendor:engine
levels/ level1.json, plus the 2019 levels under archive/
tools/ physics report, balance simulator, speed sampler, smoke test
Native ES modules, canvas 2D, no bundler and no build step — the deploy copies these files verbatim. There are no runtime dependencies.
This repo owns the game and its data. It does not own its own hosting.
| Concern | Where it lives | Deployed by |
|---|---|---|
Hosting (wesleyarcade.com/singularity/) |
Firebase project singularity-c216f |
TomWesley/WesleyArcadeSite CI |
| Auth + Firestore (leaderboard) | Firebase project singularitythegame |
this repo |
The arcade's hub repo (WesleyArcadeSite) checks this repo out during its
GitHub Actions deploy, copies public/ and levels/ into dist/singularity/,
and publishes the whole site. Pushing to main here fires a
repository_dispatch at the hub, which triggers that deploy.
The firebase.json in this repo deliberately has no hosting block. That
is not an oversight. A Firebase custom domain maps to exactly one Hosting site,
so every arcade game must be served from the hub's site -- but Firestore rules
are per-project, so each game keeps its own. Adding a hosting block here, or
pointing .firebaserc at singularity-c216f, would put this game's rules back
in the shared project where an unrelated deploy can overwrite them.
# Safe from this repo -- only ever touches the singularitythegame project.
firebase deploy --only firestore:rules,firestore:indexes
# Local emulators for auth + firestore.
firebase emulators:startusers/{uid}— public profile (displayName,photoURL). Owner-writable.scores/{scoreId}— one document per completed run (uid,displayName,levelsCleared,craftRemaining,craftId). Publicly readable, append-only: no updates or deletes, by anyone.
A survival score, not a time. The run clock was removed from the game — this is about how far you get and what you have left when you stop, not how fast you got there — so a run ranks by levels cleared, with craft still in hand as the tiebreak.
Scores are written straight from the client, so firestore.rules can validate
their shape but not their authenticity — a determined player can post a
fabricated run. Closing that requires a Cloud Function, which requires the Blaze
plan. Noted as a known tradeoff rather than an oversight.
- 2026: Full rebuild — Arcade Graphics Engine, Paczyński–Wiita gravity, fixed-timestep symplectic integration, p5.js removed
- 2025: Responsive refactor, JSON levels
- 2019–2024: Original development
Copyright © 2019 Tom Wesley
This original novelty arcade game allows users to pilot a spacecraft which surfs on the gravitational waves of black holes to explore various galaxies.