Sight, Light, Waldschattenspiel
I had a kernel of an idea for July's game while looking at my board game collection, looking for inspiration - finding a mechanic which might be interesting to adopt. In my collection is Waldschattenspiel - a game which gets players to hide in the shadows cast by a tealight, in order to navigate a forest:

The game is one of those fantastic rare board games which does something with the physical world, rather than just relying on tokens and cards. It's also a somewhat novel idea in abstract, and I thought it could be interesting to iterate on for a web based game. I also wanted to try to stretch my abilities and work on multiplayer, and this felt like the perfect excuse. I slapped together a basic demo using Kenny's Top Down Shooter asset pack. The result was... playable, albeit uninteresting to look at:

It didn't give me quite the same feel as I enjoyed from the game, rays of light
and shadow cast around. So I went looking for more inspiration around this, and
stumbled on Sight and Light, a page on how to create light effects. This was
exactly what I was after. You don't need to trace a ray for every pixel, as
light only ever changes shape at the corners of walls. A ray cast at the end of
each wall, and one either side gives you everything you need to draw a beam of
light around the corners. Sorting the hits by angle, join them up, and you have
a polygon. My version keeps the walls as a flat Float64Array of segments and
reuses its buffers between frames:
for (let i = 0; i < segs.length; i += 2) {
const a = Math.atan2(segs[i + 1] - py, segs[i] - px);
angles[n++] = a - RAY_EPSILON;
angles[n++] = a;
angles[n++] = a + RAY_EPSILON;
}
angles.subarray(0, n).sort();
for (let i = 0; i < n; i++) {
const dx = Math.cos(angles[i]);
const dy = Math.sin(angles[i]);
this.#cast(segs, px, py, dx, dy);
points[p++] = px + dx * this.#hitT;
points[p++] = py + dy * this.#hitT;
}
This also means it applies perfectly to Canvas. That can become a Path2D, and
it's a clip region in Canvas. The rest is just drawing the pre-rendered tiles
underneath:
ctx.save();
ctx.clip(this.#lightPath(map, eye, radius, cacheId));
ctx.drawImage(this.#art.floor, 0, 0, map.worldWidth, map.worldHeight);
this.#lightWash(ctx, eye, radius, monster);
ctx.restore();


The visibility polygon becomes the core mechanic of the game, it shows the player exactly where they can be caught.
Drawing the rest of the owl
You might have noticed I settled for a dungeon style theme rather than the forest theme of Waldschattenspiel. Having an explorer escaping from a monster inside a dungeon felt compelling, and enough of a deviation so as not to totally rip-off the board game.
This game also can't be a solo game. Waldschattenspiel can't be played single player, a computer can use AI, but the social aspect is what makes it compelling.
The board game has a fixed map, but I felt like a digital version needed a map generator to keep it from being too repetitive, so it generates rooms and corridors, in a way which tries to breaking sightlines. Maps are seeded with a mulberry32 PRNG, so a map can be replayed, debugged, and the algorithms tweaked. The first iteration made large open rooms which worked badly for the line-of-sight mechanic, so slowly through a few iterations I managed to find a good balance: corridors stagger between axes rather than running as a clean line, and rooms get pillars dropped in them. Then a final pass walks every row and column and drops a pillar into any straight run of floor longer than six tiles. This gives enough variety for an interesting set of maps:

Multiplayer
A huge part of this work was around multiplayer, and getting something working with WebRTC. I implemented a whole bunch of changes in HTMLGameKit and a new signalling server, so that the next game gets multiplayer by writing HTML.
WebRTC is great for peer to peer but it can't find the other browser by itself, so it required servers to bootstrap the connection. There's a bunch of machinery around ICE & STUN, and for connections behind NAT they need a TURN servers. All of this was new to me, but I slowly pieced together a Rust server to make this easy for htmlgamekit developers. The signalling server talks JSON over a WebSocket to handle bootstrapping WebRTC, with a variety of messages.
"Rooms" are six character codes you send to a friend, and they expire after a
few minutes of silence. The queue is for an open lobby, so a game registers its
slots (Dungeon has an explorer and a monster; another game might want two
reds and two blues), players join with a preference, and the server picks a
group that satisfies the slots, scoring candidates by a blend of geographic
distance (from a GeoIP lookup, so you get matched with someone nearby) and rating
spread. Ratings use Glicko-2 which seems like a good ranking system based on
my research. The rankings update only when both players report the same result,
and stored per game in SQLite along with room history.
For a small game like this, it might be overkill to have a full ranking system and WebRTC handoff (for a handful of kb of data per game), but this was built with the idea it could be used in more games and this one becomes a proving ground (I have a few prototypes which I'm building out using this too!). What took the real effort, as ever, was the unhappy paths. Tons of testing for killed tabs, lost connections, mobile networks. So peer connections heartbeat over the WebSocket every couple of seconds, so if a connection is lost players can know about it. All of this hopefully makes the next networking game way less painful to build.
All of which means the game itself has very little to do. Dungeon is deterministic: given a seed and both players' plans, both browsers compute the same map, the same moves, the same winner. So nothing about the world ever needs to cross the wire. The whole game protocol is three messages:
/** Host tells the guest which match, which map seed, and which role. */
export function startMessage(matchId, seed, hostRole) {
return { v: PROTOCOL, t: "start", matchId, seed, hostRole };
}
/** Guest sends its locked plan for one turn. */
export function planMessage(matchId, turn, steps) {
return { v: PROTOCOL, t: "plan", matchId, turn, steps };
}
/** Host releases both plans once every actor has committed. */
export function resolveMessage(matchId, turn, plans) {
return { v: PROTOCOL, t: "resolve", matchId, turn, plans };
}
The "plan" is the users turn, it's at most five strings of up, down,
left, right. The host holds onto both plans and only releases them together,
so a plan never leaks early, and both sides run the same resolveRound on the
same input. Could it be gamed? I mean sure, there are lots of ways to cheat, I'm
not trying to make it impenetrable, just to ensure good faith players can have
fun.
Go play it. If you find a map with a long, dark corridor, send me the number.

