Pixel Paw Productions home

Devlog · · Wyatt · 6 min read

Lighting a Pixel Art City at Night

Time-lapseSped up 8xDusk to night on Spruce Street, lamp by lamp.

At night in Philadelphia Fiefdom, the flat ambient light goes to zero. The moon is the only light from the sky, and everything else you see lit is lit by a real light: street lamps, shop fronts, signs, the rooms behind windows, headlamps, fires, even the ember of someone's cigarette. Where there's no light, there's deep navy shadow. The light itself snaps to a few flat levels, like a painter's value plan.

This is the fifth post on how the look works. Last time was the weather, and a lot of what makes a wet street read is the light in it.

Why turn the ambient light off?

Ambient light is the cheap fill most 3D games use so nothing goes fully black. It lights everything evenly, from every side. At night that's what makes a city look flat: every wall and alley the same dim gray, lamps or no lamps.

So the game's sky palette fades the ambient fill to nothing after dusk. By day, the sky lights the town from the whole dome: blue overhead, gold toward a low sun. At night that same sky light drops to a faint glow, and the moon is the one direct light. It rises as the sun goes and crosses the sky at midnight. A full moon gives the street a cool blue fill. A new moon gives a third as much. At dusk and dawn the light hands over from the sun to the moon while both are faint, so it never swings around the sky at full strength.

Day fill: The same block at 23:00 with the daytime ambient fill kept on, then as the game draws it.Day fill
The game: The same block at 23:00 with the daytime ambient fill kept on, then as the game draws it.The game
The same block at 23:00 with the daytime ambient fill kept on, then as the game draws it.

Nothing goes pure black, though. Every surface has a floor: a deep navy that keeps some of its own color, so dark wood stays brown and red brick stays red in the shadows. How deep that floor sits depends on the night. A clear, moonless night is darkest. An overcast night is lit from below by the city's warm glow on the clouds. Snow on the ground brightens it a lot, and heavy rain darkens it.

Full moon: The same block at 23:00 with the street's own lights switched off, so only the sky lights it: a full moon's cool fill, then an overcast night.Full moon
Overcast: The same block at 23:00 with the street's own lights switched off, so only the sky lights it: a full moon's cool fill, then an overcast night.Overcast
The same block at 23:00 with the street's own lights switched off, so only the sky lights it: a full moon's cool fill, then an overcast night.

How does light become pixel art?

The total light on each pixel snaps to a level. By day there are five: deep shadow, dim, lit, bright, and hot, which only happens right at a light source. At night four finer steps come in between them. With only the day's levels, a lamp's pool sat inside one wide level, so a car's headlamps sweeping through it rarely moved anything up a level and you couldn't see them. The edges between levels are softened a little too, so a lamp's pool fades out instead of drawing rings.

Smooth: The same lamp pools with a smooth falloff (which also comes out brighter overall), then snapped to the game's light levels.Smooth
Stepped: The same lamp pools with a smooth falloff (which also comes out brighter overall), then snapped to the game's light levels.Stepped
The same lamp pools with a smooth falloff (which also comes out brighter overall), then snapped to the game's light levels.

Highlights step the same way, in three bands, so a wet car roof or a shop window gets a flat glint instead of a smooth smear.

The trickiest part was adding lights together. Godot hands the shader its lights in an order that changes as the camera moves. My first combine depended on that order, so whole faces flipped between levels as you walked. Now the lights combine with a screen blend, which gives the same total in any order:

vec3 tot = LL_SCREEN - (LL_SCREEN - prev) * (LL_SCREEN - raw) / LL_SCREEN;
DIFFUSE_LIGHT = ll_lum_norm(tot) * ll_level_value(dot(tot, LL_LUMA));

A side effect I like: two lights of the same color don't pile up into a hot spot, while lights of different colors add.

Where do all the lights come from?

Every street lamp, shop front, sign and door lamp in the city is stored as data when the city is built. You can't give every one of them a real Godot light, so the game keeps a fixed pool: the 24 nearest lit lights get real lights, and the 6 nearest of those cast shadows (on Steam Deck, 14 and 2). The rest still glow, but their light doesn't reach the street. The pool is re-chosen four times a second, and a light that's still in it keeps its slot, so nothing blinks when the selection changes.

Lamps off: The same block with the city's own lights switched off, then on. The lit rooms behind the windows stay.Lamps off
Lamps on: The same block with the city's own lights switched off, then on. The lit rooms behind the windows stay.Lamps on
The same block with the city's own lights switched off, then on. The lit rooms behind the windows stay.

Things that move get lights from the same pool: headlamps, tail lamps, sirens, fires, the flash of a punch landing, and a smoker's cigarette ember. Traffic signals light the street in their current color, and the fountains have uplights at night.

Under each street lamp, the ground right below sits in the fixture's soft round shadow, the way it does under a real lamp head.

When do the lights come on?

Street lamps come on as the daylight drops below a threshold, each one at a slightly different point, so a street lights up lamp by lamp instead of all at once. A heavy storm darkens the sky enough to bring them on early, the way dusk would.

Shop fronts light up while their shop is open after dark. Signs keep their own hours. The rooms behind the windows keep their household's.

What's behind the windows?

Real rooms. The upper windows nearest you have furnished 3D rooms behind them, and the nearest few are lit by their own real lamp. Rooms farther off are lit inside the shader, which works out the same ceiling light the way Godot would and steps it to the same levels. By day, sun coming in through a window lights a patch on the floor and bounces it around the room.

Do the lights ever misbehave?

On purpose, a few do. Most lamps hum along steady. About 3% of street lamps are failing and now and then stutter in a quick burst of blinks. Another 2% are old sodium lamps that go out for a while and strike back up in steps, dim first. Hit a lamp post hard enough and its bulb stutters for a moment.

It has to stay rare. With light levels, a dimming lamp drops the street around it a whole level, so it has to read as life, not a glitch.

Neon signs mostly burn steady. Some chase, blink, step through a palette of pink, cyan and amber, or buzz. Each sign's real light follows its glass, so a blinking sign blinks the wall in front of it too. And on warm, dry, still nights, moths gather around the lit lamps.

A failing lamp on an otherwise steady street. Its light on the shop front stutters with it.

Quick answers

Does every light cast shadows? No. The nearest few do. The rest light the street without shadows, and the farthest only glow.

Why snap light to levels? It's how pixel artists paint light: a few flat values with clear edges. A smooth falloff gets crushed into blotches at low resolution.

Is it ever fully dark? No. Every surface keeps a navy floor that holds some of its own color, so you can always read the street.

Follow along

Philadelphia Fiefdom is an online sandbox set in Philadelphia. This wraps up the posts on the look; if you missed it, the last one was about rain and snow that build up. Next I'll get into the street trees, and how they went from a scatter of leaves to stylized crowns. Join the playtest list to play it early, or come say hi on Discord (opens in a new tab).

Get updates by email

One email when there's something worth seeing: new footage, playtest invites, the day the Steam page goes up and launch day.

No spam. Unsubscribe any time. Privacy

Want to play early? Join the playtest list. Rather chat? Hang out on Discord (opens in a new tab).