Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Night City

Play it · write-up at jackcoldrick.com/nightcity

A side-scrolling pixel-art city that is about 99% a still painting. You walk right, and the place quietly gets on with being alive around you: windows go out in towers a mile away, a lamp stutters, a car crosses a gap between buildings, stars come and go, dust drifts through the railings in front of you.

It is built to receive AI-generated art. tools/PROMPTS.md is half the project — it is the prompt pack that gets fifteen separately generated images to look like one city, and it is written against what the engine actually needs rather than in the abstract.

Start here: getting real art in

Four steps, repeated once per image. 21 images in total.

1. Open prompts/01-sky.txt. Copy everything below the dashed line.

2. Paste it into ChatGPT as one message. It returns an image.

3. Download that image to raw/sky.png. The filename matters — it is how the next step knows what the image is meant to be.

4. Run:

python3 tools/conform.py raw/sky.png

Or download all 21 first and do them in one go at the end:

npm run conform      # conforms everything in raw/, then tells you what to redo

That writes assets/sky.png and prints two numbers:

  block size: 4 (1536/384);  measured variance at 4: 0.0
  palette: mean distance 0.0 (clean)

Both clean → done, move on to prompts/02-stars.txt. Either one flagged → paste the prompt again with the correction from the table at the end of tools/PROMPTS.md. Expect to re-prompt; first attempts usually miss something.

Work through prompts/01 to prompts/13 that way. A few files say run this prompt N times on line 2 — those are the layers with variants, and each run becomes mid_0.png, mid_1.png, mid_2.png and so on.

Then refresh the browser. No code changes at any point.

Two things to leave until last. prompts/13-character-sheet.txt needs one extra command afterwards (npm run frames). And the masks — the thing that tells the engine where each window is — are a separate job described under Masks in tools/PROMPTS.md. Until you do them, windows stay dark and everything else works.

Run it

npm run dev        # python3 -m http.server 5174

Then http://localhost:5174. No build step, no dependencies — static files and ES modules.

Key Action
A / D (or arrows) Move
Shift Run
Space / W Jump (variable height — release early to hop)
E Interact with whatever you are standing next to
M Mute
` Debug overlay
URL flag What it does
?static=1 Freezes all ambience, for an A/B against the still painting
?grid=1 Tile boundaries per layer, and the ground line
?seed=N Reshuffles every light and timer

The asset pipeline

python3 tools/conform.py raw/sky.png   # a generation -> a usable asset
npm run art       # crude placeholder layers + masks + character sheet
npm run frames    # character sheet -> src/frames.js
npm run lights    # the *_mask.png files -> src/lights.js
npm run prompts   # tools/PROMPTS.md -> prompts/*.txt, one paste-ready file each

tools/PROMPTS.md is the manual — the pipeline, the rules and the reasoning. prompts/ is what you actually paste: one file per asset, each already carrying the shared preamble, one ChatGPT message each. Regenerate it after editing PROMPTS.md; never edit prompts/ by hand.

src/frames.js and src/lights.js are generated. Do not edit them.

The one decision everything rests on

All world drawing happens on an offscreen 320x180 buffer, which is blitted once to the visible 1280x720 canvas with smoothing off.

The previous project (~/Desktop/dev/interactivecv) drew large source art at 4x with no internal buffer, which is why it never quite reads as pixel art — "pixels" were whatever the generator happened to make, and anything drawn in code landed between them. With a real low-res buffer a twinkling star is literally one pixel, a glow is a few chunky rings, text is 8px native, and nothing can end up off-grid by accident.

Everything else follows from it. Physics is in 320-wide units. The character is 32px tall. Text is 40 characters to a screen.

The layer stack

Seven layers, tiling forever in both directions. f is the parallax factor.

Layer f Variants Carries
sky 0.00 1 vertical gradient, zero horizontal variation
stars 0.05 1 star field + star mask
moon 0.03 sprite body only; the halo is code
far 0.15 1 distant silhouettes
mid 0.35 3 towers + window mask
near 0.65 3 facades, shopfronts, lamps, neon + masks
street 1.00 3 pavement, kerb, road — the walkable plane
fg 1.35 2 railings and clutter, drawn over the player

Layer offsets are Math.floor(-cam * f). The floor is not optional. Sub-pixel parallax makes every layer shimmer at once, and it is the single most recognisable way for pixel art to look cheap.

Why tiles have gutters

Art arrives as 1536x1024 generations, conformed down by 4 to 384x256 tiles. A seamless tile — where the right edge continues perfectly into the left — is something image generators cannot reliably produce.

So we do not ask for one. Instead the leftmost and rightmost 8 pixels of every tile must be empty sky or plain road, with nothing crossing them. Then any tile can follow any other and the join is invisible, because there is nothing at the join to misalign. tools/conform.py checks this and reports which rows are wrong — it caught a real one in this project's own placeholder art, where a 6-pixel road dash started inside the safe zone and ended inside the gutter.

Why three variants, and why not for the far layers

A single 384px tile repeats every 1.2 screens, which is glaring on the near layers. Three interchangeable tiles picked by a seeded hash push the repeat out to several screens and make it non-obvious. sky, stars and far stay single-tile: nobody clocks a repeating silhouette at 0.15 parallax, and three variants of it would be three generations spent on something invisible.

More importantly, a tile's lights are keyed on (seed, layer, tile index), not on which art variant it drew. So two tiles using the same image light up completely differently — which is most of what stops a repeat reading as a repeat, at a fraction of the art cost.

The ambience system

src/ambience.js. The reusable part, and the point of the whole thing.

Most lights never change

The masks yield ~2,300 windows. Giving each one an independent 20–90 second timer works out at roughly forty state changes a second, which does not read as a living city — it reads as static.

So a window's on/off state is decided once from the seed and then left alone. Only 3% are wired to a timer at all. On screen that is a window changing somewhere in the skyline every couple of seconds, which is about right.

Everything else runs on an independent random phase, because anything synchronised reads as a machine and the eye finds it instantly.

Glow is code, not art

Every halo — lamps, neon, the moon, headlights — is drawn additively at runtime. That is what lets a lamp warm up as you approach it and a neon tube buzz. A painted halo cannot dim, pulse or react, which is why tools/PROMPTS.md treats baked glow as making a generation unusable rather than merely imperfect.

It is drawn as five concentric rings with a quadratic falloff rather than a radial gradient. Three rings — the first attempt — left a hard circular edge that read as a disc sitting on the picture rather than as light falling off.

Proximity is eased, not switched

Lamps and props respond to how close you are on a continuous curve, over about a third of a second. A prop that snaps the frame you cross a radius reads as a trigger volume; one that eases reads as noticing you. Same instinct as Career Quest's doors: things react as you approach, they never take control away.

Lamp proximity is measured on screen, not in the world. A lamp on the 0.65 layer is not on the player's plane, so comparing world x warms a lamp that is visibly half a screen away.

Measuring the 90/10 target

The debug overlay reports what share of the screen is in motion, averaged over frames where the camera was still. Currently ~0.4%.

The obvious metric — the fraction of frames with any motion at all — was tried first and is useless: one distant car crossing makes it 100%, which says nothing about how busy the scene looks. And it only samples at rest, because once you walk, parallax changes every pixel on screen and the number would be a constant 100%. A mostly-still painting is a claim about the scene standing still.

What was borrowed from interactivecv

Copied in rather than imported, so the projects stay independent:

  • tools/extract_frames.py — connected-component slicing plus a per-frame anchor solved by silhouette cross-correlation. That solved a genuinely hard problem (interactivecv's README documents the several obvious approaches that all fail); every area and distance threshold is scaled from its 138px character to this project's 32px one, and the algorithm is untouched.
  • src/anim.js — distance-driven locomotion and phase-preserving walk↔run.
  • src/player.js — coyote time, jump buffering, variable jump height, skid, squash & stretch. Every constant was picked in metres against a 410px character and tuned by feel, so they were re-multiplied by this project's 18px-per-metre rather than guessed again. Jump height lands at 0.61 of body height, the same proportion as the original.
  • src/particles.js and the pixel-font pipeline.

The text pipeline got simpler in the move: the original had to render at 8px, force to 1-bit, then magnify by an integer. Here the buffer is already low-res, so 8px native is the final size — the same 4x blit that scales the art scales the text.

Grain, and where it comes from

Generators dither. At full size it reads as texture; reduced by four it becomes single stray pixels sitting on flat colour, and a few thousand of those is the difference between "pixel art" and "noisy". Conformed tiles were up to 3.3% isolated pixels. conform.py now replaces any pixel matching none of its four neighbours with the most common colour around it, which took a typical tile from ~1,000 stray pixels to single digits. The stars and the moon are exempt: there, a lone pixel is the whole point.

Sizing things against the character

The character is 30px, which puts a metre at about 17px, which makes a 4.5m car 76px long. The near car was specified at 26px and read as a toy parked next to a person -- the kind of mistake that is invisible in a spec and obvious on screen. Anything with a real-world size should be derived from the character, not picked.

A car at night is also its lights, not its body: a flat dark silhouette on a dark road is invisible without them. Both lights are located by scanning the sprite for the palette's headlight and taillight colours -- the same trick as the lamp's bulb, which is found by diffing its lit and unlit cells. Neither position is hardcoded, so re-generated art keeps working.

Things that are deliberate

  • The debug overlay draws on the visible canvas, not the buffer. In the buffer its 8px glyphs magnified to 32px and ate a third of the frame to say "fps 60". It is an instrument, not part of the picture, and drawing it at native size makes it four times as dense and unmistakably not art.
  • Big lit panes get the dim colour. A lit rectangle reads as bright in proportion to its area. A shopfront pane at full warm outshone the moon.
  • There is no camera clamp and no level width. The city tiles forever both ways; tile indices go negative if you walk left of the origin.
  • window.__game exposes tick(dt), so a pose can be driven deterministically from the console — Chrome parks requestAnimationFrame in a backgrounded tab, which will otherwise look exactly like the game has hung.

Sound

Web Audio rather than <audio> elements: one-shots have to overlap without cutting each other off, and a car passing has to move across the stereo field, which an element cannot do.

Everything is gated behind a user gesture because browsers require one. That is not a limitation here -- pressing START is the gesture, so the city arrives with its sound at the same moment the camera begins to move.

The hooks are all existing events rather than new timers, which is why nothing drifts:

  • footsteps fire on the animator's contact events, so the sound lands exactly when the foot is planted. Pitch is scattered +/-8%, because a footstep repeated at one pitch stops being a footstep and becomes a metronome.
  • car pass-by fires from spawnCar and sweeps its stereo pan from the side the car enters to the side it leaves. Distant traffic is quieter and stays nearer the centre -- further away across and in depth.
  • the busker's guitar is a loop that runs from the start at zero gain and is brought up by proximity, so you catch it already in progress rather than hearing it start from the top like a trigger.
  • sirens ride the same ambience scheduler as everything else, every 70-190s, panned hard to one side: a street you are not on.

Levels are in LEVEL in src/audio.js, deliberately low. The loudest thing in a quiet street at night should still sit under the room you are actually in.

Known gaps

  • Neon signs are generated (assets/neon.png) but not placed: the facade art has no reliable marker for where a sign hangs. Lamps hit the same problem and were solved by making them street-plane sprites; signs could go the same way.
  • The mid and far skylines are largely hidden behind the near facades, which came back taller than either. Re-generating those two with the storey counts now in the prompts would restore the depth.
  • The audio kit is 6.4MB of uncompressed mono WAV. Fine locally; it wants converting to Ogg/AAC before this is ever served over a network.
  • No mobile or touch support; keyboard only.
  • window.__game, the debug overlay and the URL flags all ship.
  • Hotspot copy is filler.

Layout

index.html            shell + controls legend
style.css
src/game.js           loop, low-res buffer, camera, draw order
src/layers.js         the parallax stack and the tiling renderer
src/ambience.js       the life system: scheduler, emitters, budget
src/hotspots.js       the interactables
src/rng.js            seeded hashing — identity is (seed, layer, tile, index)
src/text.js           8px 1-bit pixel text
src/particles.js      dust and drift
src/player.js         physics + animation state machine   (from interactivecv)
src/anim.js           clip definitions and the Animator   (from interactivecv)
src/frames.js         GENERATED — do not edit
src/lights.js         GENERATED — do not edit
tools/PROMPTS.md      the prompt pack (the manual)
tools/build_prompts.py    PROMPTS.md -> prompts/*.txt
prompts/              GENERATED — paste-ready, one file per asset
tools/conform.py      generation -> conformed tile, with the checks
tools/extract_lights.py   masks -> src/lights.js
tools/extract_frames.py   character sheet -> src/frames.js  (from interactivecv)
tools/placeholder_art.py  stand-in art at exact dimensions
assets/               layers, masks, sprites, palette.png

About

A side-scrolling pixel-art city built to receive AI-generated art, plus the tooling that makes separately generated images look like one place.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages