There's a dungeon under this blog
There’s a dungeon under this blog
这个博客下面藏着一个地下城
Depth 1 of that day’s dungeon. Tiles from Kenney’s Tiny Dungeon, CC0. There’s a dungeon under this blog, every page of it. Find the way in and you get a little pixel character (a knight, a wizard, a princess etc; it depends on a hash of your peer id) standing in a sunlit village square. Through the stone arch north of the well, a procedural dungeon crawl: monsters, potions, chests that sometimes have teeth. If someone else is reading the blog right now, they can join in the same village, the same dungeon. You can watch them wander. You can shout at each other. 这是当日地下城的第一层。素材来自 Kenney 的 Tiny Dungeon,采用 CC0 协议。在这个博客的每一页下面,都藏着一个地下城。找到入口,你就会获得一个像素小人(骑士、巫师、公主等,取决于你节点 ID 的哈希值),它会出现在阳光明媚的村庄广场上。穿过水井北侧的石拱门,就是一个程序生成的地下城探险:有怪物、药水,还有偶尔会长出牙齿的宝箱。如果此时还有别人在阅读这个博客,他们也可以加入同一个村庄、同一个地下城。你可以看着他们游荡,也可以互相大喊大叫。
The blog is static so there is no game server, no websocket backend, no database. Multiplayer is handled completely peer to peer. 这个博客是静态的,所以没有游戏服务器,没有 WebSocket 后端,也没有数据库。多人游戏完全通过点对点(P2P)方式实现。
Two browsers, no middlemen
两个浏览器,无需中间人
WebRTC lets two browsers open a data channel directly to each other. The catch has always been the introduction: before peers can talk, they have to exchange connection offers through something they can both reach, and that something is traditionally a signaling server you run. WebRTC 允许两个浏览器直接建立数据通道。但难点一直在于“介绍”环节:在节点能够对话之前,它们必须通过某种双方都能访问的媒介交换连接请求,而这种媒介通常是你自己运行的信令服务器。
Trystero’s trick is doing the introduction over infrastructure that already exists and belongs to nobody. By default it uses public nostr relays; it can also ride BitTorrent trackers or MQTT brokers. Everyone who opens the dungeon joins a room named after the UTC date, announces themselves through the relays, and completes the handshake. From then on the relays are out of the loop and game traffic rides peer-to-peer. Trystero 的诀窍在于利用现有的、不属于任何人的基础设施来完成这个介绍过程。默认情况下,它使用公共 Nostr 中继;它也可以利用 BitTorrent 追踪器或 MQTT 代理。每个打开地下城的人都会加入一个以 UTC 日期命名的房间,通过中继宣告自己的存在,并完成握手。此后,中继便不再参与,游戏流量直接在点对点之间传输。
The entire server-side footprint of multiplayer is one edit to the Content-Security-Policy header: connect-src now allows wss: so the relay websockets can connect. 多人游戏在服务器端的全部痕迹,仅仅是对 Content-Security-Policy 头部的一次修改:connect-src 现在允许 wss:,以便中继的 WebSocket 可以连接。
The map stays home
地图留在本地
The obvious multiplayer design has someone generate the dungeon and send it to everyone else. Nobody sends anything here. Every client derives the day’s date, hashes it into a seed, and feeds it to a mulberry32 PRNG. Same seed, same rooms, same corridors, same monsters standing in the same spots, on every machine that opens the dungeon that day. Depth 3 is generated from the day seed xored with the depth times a golden-ratio constant, the moment you reach it, so the whole descent is implied by a constant number nobody transmitted. 显而易见的多人游戏设计是让某个人生成地下城并发送给其他人。但在这里,没有人发送任何东西。每个客户端都会获取当天的日期,将其哈希成一个种子,并输入到 mulberry32 伪随机数生成器中。相同的种子,相同的房间、走廊和怪物位置,在当天打开地下城的每一台机器上都是一致的。第三层是在你到达的那一刻,通过当日种子与(层数乘以黄金比例常数)进行异或运算生成的,因此整个下潜过程都隐含在一个无人传输的常数中。
What actually goes over the wire is live state: positions, hit points, the occasional “I opened this chest” so loot doesn’t respawn for the next visitor. A full update is under a hundred bytes. 实际在网络上传输的是实时状态:位置、生命值,以及偶尔出现的“我打开了这个宝箱”的消息,这样战利品就不会为下一位访客刷新。一次完整的更新不到一百字节。
The world resets at midnight UTC. Whoever is mid-crawl keeps their run, but they’ve become a ghost of yesterday: new arrivals join tomorrow’s room and the two eras can’t see each other. Gold is per-run either way. The dungeon keeps it when you die. 世界在 UTC 午夜重置。正在探险的人可以继续他们的旅程,但他们变成了昨天的幽灵:新来者会加入明天的房间,两个时代的人互不可见。无论如何,金币是按单次探险计算的。当你死亡时,地下城会保留这些金币。
Somebody has to be the dungeon master
总得有人当地下城主
Deterministic generation gets everyone the same starting monsters, but then the monsters have to move, and with no server there’s no referee to move them. Two players watching the same slime need to agree on where it went. 确定性的生成让每个人拥有相同的初始怪物,但怪物随后需要移动,而在没有服务器的情况下,没有裁判来移动它们。两个看着同一只史莱姆的玩家需要就它去了哪里达成一致。
The dungeon elects one. Every peer id is a random string, so the lexicographically lowest id on a given depth becomes that depth’s authority: it steps the monsters every 400 ms, resolves attacks, and broadcasts the results. Everyone else mirrors. When the authority closes their tab, the next-lowest id notices and takes over from the last broadcast state, because every client was already keeping a copy. Solo play is the degenerate case where you’re always the lowest id on your floor. 地下城会选出一位“主宰”。每个节点 ID 都是一个随机字符串,因此在特定层级中,字典序最小的 ID 成为该层的权威:它每 400 毫秒移动一次怪物,处理攻击,并广播结果。其他人则进行镜像同步。当权威关闭标签页时,下一个最小的 ID 会察觉到并从最后一次广播的状态接管,因为每个客户端都已经保留了一份副本。单人游戏则是最简单的情况,因为你永远是你所在楼层 ID 最小的那个。
The scheme leans on the same determinism everywhere. Mimics are the cleanest example: some chests are secretly monsters, the choice of which falls out of the day seed, and so every player is lied to by the same chest. 这个方案在各处都依赖于同样的确定性。宝箱怪(Mimics)是最典型的例子:有些宝箱暗中是怪物,而哪些是怪物取决于当日种子,因此每个玩家都会被同一个宝箱“欺骗”。
What a page pays
页面付出的代价
An easter egg on every page has to cost nearly nothing on every page. The always-loaded part is a 1,057-byte script that listens for the way in and does nothing else. When someone gets through the door, it dynamic-imports the actual game: 94 KB of bundle (Trystero included) and a 10 KB tileset. 每个页面上的彩蛋必须几乎不占用任何资源。始终加载的部分是一个 1,057 字节的脚本,它只负责监听入口,不做其他任何事。当有人穿过门时,它会动态导入真正的游戏:94 KB 的包(包含 Trystero)和 10 KB 的图块集。
The art is Kenney’s Tiny Dungeon and its sibling pack Tiny Town (the village), 132 CC0 tiles each, composited into one sheet; between them I’m using about ninety. The sounds ship in zero bytes: every effect is a couple of Web Audio oscillators with a gain envelope, synthesized on the spot. Potions bubble upward, damage slides downward, the mimic is two detuned sawtooths. Press m if it gets to be too much. 美术素材来自 Kenney 的 Tiny Dungeon 及其姊妹包 Tiny Town(村庄),每个包有 132 个 CC0 图块,合成在一张图上;我大约使用了其中的九十个。声音占用零字节:每个音效都是几个带有增益包络的 Web Audio 振荡器,现场合成。药水冒泡声向上滑动,伤害声向下滑动,宝箱怪的声音是两个失谐的锯齿波。如果觉得太吵,按 m 键即可。
The walls know which way they face
墙壁知道它们朝向哪里
Most of this was built by Claude Fable in an evening of back and forth, and the division of labor was what you’d hope: I said things like “make it a dungeon crawler” and “the message scrolls could be punchier,” and it wrote the netcode and then proved the netcode worked by launching two headless browser that found each other through real public relays and screenshotted themselves seeing each other. Watching two robots meet in your dungeon is a strange kind of code review. 大部分内容是由 Claude Fable 在一个晚上的反复沟通中构建的,分工正如你所期望的那样:我说“把它做成一个地下城探险游戏”和“消息滚动条可以更带感一点”,它就写出了网络代码,并通过启动两个无头浏览器证明了代码有效——它们通过真实的公共中继找到了彼此,并截屏证明了它们看到了对方。看着两个机器人在你的地下城里相遇,这是一种奇特的代码审查方式。
My actual contribution to correctness was stuff like noticing the walls looked wrong. Kenney’s tiles are directional: some bricks carry an edge shadow meant for the boundary where a wall meets the void, and the first version sprinkled them anywhere, so south-facing walls wore east-facing textures. The fix came from the tileset itself. The zip ships a Tiled sample map, and decoding it (gids, flip flags and all) revealed the intended grammar: walls that touch floor are plain seamless brick, ornament goes on south faces only, and a dripping gargoyle expects a goo basin on the tile below it. The renderer now follows the grammar, and the gargoyles drip where they’re supposed to. 我在正确性方面实际的贡献是注意到墙壁看起来不对劲。Kenney 的图块是有方向性的:有些砖块带有边缘阴影,用于墙壁与虚空交界的边界,而第一个版本把它们随意放置,导致朝南的墙壁用了朝东的纹理。修复方案来自图块集本身。压缩包里有一个 Tiled 示例地图,解码它(包括 gid、翻转标志等)揭示了预期的语法:接触地面的墙壁是平整无缝的砖块,装饰只出现在朝南的面上,滴水的石像鬼下方必须有一个粘液盆。渲染器现在遵循了这个语法,石像鬼终于在它们该滴水的地方滴水了。
Shouting into the dark
对着黑暗呐喊
A shared dungeon where you never find anyone is a lonely piece of engineering, so there’s a shout button. Shouting broadcasts your position, and everyone on your floor hears a two-note call panned left or right by where you are relative to them, faded by distance, with a compass line in the message scroll and a brief mark over your head that ignores line of sight. Sound goes through walls; that’s the point of shouting. If you’re on a different floor the shout ar… 一个永远遇不到人的共享地下城是一个孤独的工程,所以有一个“呐喊”按钮。呐喊会广播你的位置,你所在楼层的每个人都会听到一个双音呼叫,根据你相对于他们的位置进行左右声相平移,并随距离衰减。消息滚动条中会出现一条指南针线,你的头顶会出现一个忽略视线遮挡的简短标记。声音可以穿墙;这就是呐喊的意义所在。如果你在不同的楼层,呐喊会……