Replace Node/Socket.IO/Pug with a measured Zig epoll server - #13
Open
JanTuck wants to merge 44 commits into
Open
Replace Node/Socket.IO/Pug with a measured Zig epoll server#13JanTuck wants to merge 44 commits into
JanTuck wants to merge 44 commits into
Conversation
- player.js: hasEaten was a module-level variable shared by every Player, so one player eating made ALL snakes grow while the eater could miss its growth. Growth is now per-instance (growNextTick) via eat(). - player.js: add setDirection() which whitelists the four arrows, rejects 180-degree reversals and no-ops, and queues at most one turn per tick so rapid double presses cannot smuggle a reversal. - player.js: collidedOther() now compares heads against other snakes only (the old all-segments-vs-all-segments loop killed innocent snakes on any overlap), skips self cleanly and returns the colliding Player. - player.js: updatePosition() is stationary until the first input, keeps bodyLength in sync, and collided() returns a real boolean. - environment.js: startPosition() discarded its recursive result, so spawns could overlap existing snakes; now retries iteratively and avoids occupied cells. - inputvalidation.js: reject non-strings (undefined.length TypeError), trim, enforce 4-16 chars and a safe unicode charset. - generateId.js: replace deprecated substr and mix in time for collision-resistant lobby ids.
- GET / sent a directory to sendFile (EISDIR 500); serve index.html properly. - /generateid was a no-op stub: it now creates a lobby id and redirects to the game; /joingame validates gameId type (req.body may be undefined for unparseable bodies) and encodes the redirect. - /game.html is no longer served by the catch-all static middleware, which bypassed the lobby gate entirely. - game loop: dead players no longer keep moving or appear in broadcasts; per-tick snapshot prevents double-processing; one io.emit of clean DTOs replaces per-socket re-serialization of Player instances (which leaked internal fields); broadcasts go through a lobby room so dead sockets stop receiving state. - the 15fps interval now stops when nobody is playing and restarts on join. - food respawns avoid snake bodies; spawns also avoid the food cell. - keyPress before joining no longer crashes; disconnect cleanup is unconditional. - add PORT env support, EADDRINUSE-safe error handler, graceful SIGTERM/SIGINT shutdown, basic security headers, x-powered-by disabled. - error.pug rendered a hardcoded 404 for every error; it now shows the real status and message.
- userInput.js: non-arrow keys poisoned the direction tracker and defeated the reversal guards (instant wrongful deaths); arrows now preventDefault, auto-repeat is ignored and the tracker only updates for arrows. - rendering.js: socket handlers were wired inside window.onload, so slow font loads soft-locked joining players (init fired with nobody listening); wiring now happens at module evaluation. Snakes are reused across ticks instead of rebuilt, clearRect uses canvas dimensions, game_error uses textContent with a single resettable timer, the death screen is guarded against overdraw and shows a disconnect notice. - rendering.js: the game-over Retry button is actually clickable now (canvas hit-testing with CSS scaling); it previously rendered two overlapping, inert buttons. - snake.js: drop the never-sent pressing* wire fields and module-level leftovers; add update() for tick reuse. - resourceHandler.js: getImage() actually returns the image. - game.html: serve the version-matched socket.io client from the server instead of a pinned 2.2.0 CDN copy; real form semantics (Enter submits, minlength/maxlength matching the server rule, label, role=alert banner); localStorage guarded with try/catch and a correct null check; automatic rejoin after reconnect. - game.css: stop non-uniform stretching of the 2:1 canvas, style the popup controls (they only existed in the unloaded index.css), visible focus rings. - index.html/lobby.html: drop unused CVE-laden jQuery 3.4.1, fix the its typo, viewport meta, favicon placeholder, labelled inputs; lobby page loses its dead script. - index.css: readable heading contrast and focus-visible outlines.
- ignore node_modules, local scratch and IDE files. - weekly Dependabot updates for npm (minor/patch grouped) and github-actions.
- POST redirects now use 303 so refreshing the landing page cannot resubmit the form (PRG pattern). - /joingame trims the submitted id, so copy-pasted ids with stray whitespace work. - Unknown or invalid ids no longer bounce home silently: the redirect carries ?error=unknown-game and the home page shows a visible explanation.
- rendering.js no longer re-exports SCALE: the rewrite had dropped the export, which broke ES module linking for the whole game page (gameObject.js imports it), so no client code ran at all on /game/<id>. The circular dependency is gone entirely - GameObject takes its cell size directly. - game page: intentional dark backdrop with the board vertically centred (was a white dead-band), subtle 120x60 grid showing through the cleared canvas, board shadow. - home page: centred label/input composition, removed the half-rendered diagonal sweep that only appeared on the <button> variant, readable heading contrast kept, responsive container (min(420px, 86%)) so buttons no longer clip on phones. - snake name labels flip below the head near the top edge instead of clipping; game-over Retry label is bigger and bold. - verified with a Playwright/Chromium suite: create->join->play, two-player visibility, cross-client movement, unknown-id banner, death->retry, plus desktop/narrow screenshots.
- Supply crates drop in every 12-20s (max 2 concurrent, 25s despawn with blink warning). Opening one scores +2, grows the snake by 2 and bursts 4 bonus apples onto the board. - Golden apple spawns periodically, is worth 3 points and vanishes after 12s. - Bonus apples (capped at 12) behave like regular food; pickups never spawn on snakes or other pickups. - eat() now takes points/growth amounts (pendingGrowth counter replaces the boolean). - New 'feed' event stream (join/death/drop/golden) drives the client HUD feed and sounds. - gameTick now carries the full world state (players + pickups) as one broadcast. - Disconnected-socket zombies (closed tab, dropped network) are reaped within a tick and announced in the feed; explicit pingInterval/pingTimeout heartbeats.
…creens - Ready-made Twemoji sprites (CC-BY 4.0, credited in client/img/CREDITS.md) for apples, the golden star, supply crates and feed icons, with procedural fallbacks so the game still works fully offline. - Rendering moved to a requestAnimationFrame loop with per-tick interpolation: movement is smooth at display refresh rate instead of jumping 16px every 66ms. The snake head now has direction-tracking eyes; body segments are rounded with alternating shading. - Particle bursts for eats, crate openings and deaths - death bursts now happen where the snake actually died, not screen centre. - WebAudio synth SFX (eat, golden, crate, death, join) with a persistent mute toggle; no audio files needed. - DOM HUD: live score panel, top-5 leaderboard and an event feed, plus GSAP (served locally from node_modules, no CDN) for popup/feed/score animations. - Game-over screen can no longer be wiped by the render loop (regression-tested via canvas pixel sampling), and its Retry button is themed. - Home and join screens redesigned around the Snek identity: snake logo + wordmark, dark card matching the arena, apple-red join / leaf-green create actions, responsive down to phones, visible focus states. - Fixed: hud.js referenced an unimported Sfx module (mute click would throw).
- Every game id is now an isolated arena: own food, supply drops, golden apple, bonus apples, tick state and broadcast room. Different ids = different games running concurrently. - Lobbies are created via POST /generateid, deleted after 60s idle (the default 12345 lobby always stays). - Player caps: 100 concurrent across the server, 16 per lobby - both reject with a clear game_error message. - The client passes its lobby id (from /game/<id>) with clientReady; unknown lobbies are rejected with a visible error instead of silently joining nothing. - Chained direction queue on the server (max 2, validated against the last queued turn): rapid L-shaped inputs register instantly, reversals remain impossible. - Same-socket rejoin after death now works (socket.player is the source of truth; Retry without reload re-enters the arena). - Zombie players (dead sockets) are reaped within one tick; explicit heartbeats. - game.css: global box-sizing so the join input no longer overflows the popup card. - Optional SNEK_DEBUG=1 /debug/stats endpoint for benchmarking.
- tools/bench.js: unified battle/benchmark harness (tick cadence, payload sizes, 5-80 bot load ramp, input latency, churn, hostile floods + reconnect storms, HTTP load, /debug/stats sampling) - works against any Snek server implementation. - Home screen: action button icons, apple-red card accent.
Owner
|
10/10 AI Slop |
Author
10/10 AI slop, but the slop holds 12k players in 20MB of RAM so unfortunately I'm keeping it |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Executive summary
This PR replaces the production Node.js/Express/Socket.IO/Pug server with a single, static, Linux-native Zig executable and takes the opportunity to finish the game around it: real lobby isolation, bounded capacity, compact binary networking, deterministic connection lifecycle, password-protected lobbies, a Classical mode, a redesigned lobby/game UI, a full-viewport authoritative board, and a deployment image small enough to be mistaken for a rounding error.
The short version is:
start-docker.shbuilds the Zig executable on the host and publishes a compiler-free, runtime-onlyscratchimage on port9687.This is intentionally a large PR. It is not a cosmetic language port. It is the result of repeatedly profiling, benchmarking, deleting, bounding, and adversarially testing the complete server/client path until the remaining optimization ideas were either noise-scale, harmful elsewhere, or not worth their complexity.
Friendly-fire disclaimer
Jan and I are friends, so I am going to describe the old stack with the tenderness it deserves: very little.
Node.js is not intrinsically incapable of serving a multiplayer game. It is, however, spectacularly good at letting a tiny project accumulate an HTTP framework, a WebSocket abstraction layered over another WebSocket abstraction, a template engine, two body parsers, cookie middleware, logging middleware, error middleware, transitive dependency archaeology, and a
node_modulesdirectory with the gravitational pull of a small moon—all before the snake has moved one cell.The actual problem was not JavaScript syntax. The problem was using a general-purpose web application stack on a fixed-cadence, stateful, high-fan-out simulation hot path and then representing the hottest data as verbose object-shaped JSON. Zig makes the new architecture explicit and enforceable, but the gains come from the architecture: bounded state, raw sockets, compact protocols, controlled ownership, fewer syscalls, fewer allocations, fewer copies, fewer threads, and fewer bytes.
A loving autopsy of the old Node stack
The original
package.jsonneeded these production dependencies:That is an impressive committee for a server whose essential responsibilities are:
Instead, the old server booted Express, created a separate HTTP server, attached Socket.IO, configured a Pug view directory, installed Morgan, installed
body-parsertwice in spirit (urlencodedand JSON), installedcookie-parserdespite the game not needing cookie state, served two static roots, forwarded errors throughhttp-errors, and finally rendered the error through Pug.The Pug situation
The Pug usage deserves its own archaeological plaque.
The game itself was served as static HTML from
client/. Pug was configured as the application view engine, but its meaningful runtime role was primarily to render the error path throughviews/error.pug,views/layout.pug, andviews/layout_error.pug. In other words, an entire template compiler and its dependency tree were invited into production so a 404 could put on formalwear.This is not progressive enhancement. This is making an error page pass through a templating language while the actual application walks around it and uses plain HTML.
The new server responds directly with bounded static or generated bodies. There is no template compiler, no dual static/template rendering model, no view lookup, no template runtime, and no opportunity for
views/layout_error.pugto become the most architecturally pampered file in the repository.Pug has not been “optimized.” Pug has been given the greatest optimization available to dead weight: deletion.
The Express middleware buffet
The previous process paid for middleware and abstractions the game did not need:
cookie-parserwas registered without a cookie-backed game design.body-parserwas an extra dependency even though modern Express already provides body parsing helpers.debugremained in the dependency surface without being the production observability design.http-errorsmanufactured an object so Express could pass it through middleware so Pug could turn it back into HTML.express.staticroots and bespokesendFileroutes overlapped responsibilities.GET /generateidloggedreq.body, which is an especially avant-garde interpretation of both GET semantics and lobby creation.The Zig server has an explicit route table and parser limits. It handles the small protocol this application actually owns. Static assets are embedded at compile time. There is no middleware conveyor belt and no runtime filesystem dependency.
Global state pretending to be lobbies
The old code declared global
SOCKET_LIST,LOBBY_LIST,BOT_LIST,FOOD_LIST, and one globalfood.LOBBY_LISTcontained"12345" -> null, while connected players lived in the one global socket map.io.emit('updateFood', ...)broadcast the shared food update to every connection.That is not lobby isolation. That is one room wearing a lobby-shaped nametag.
The new implementation has independent lobby ownership, player lists, PRNG state, food, bonus apples, drops, golden apples, timers, metrics, passwords, mode flags, and worker assignment. A player joins exactly one lobby. Roster and world updates are scoped to that lobby. Empty generated lobbies are reaped after a bounded idle lifetime. Capacity is enforced globally and per lobby.
The game loop and fan-out shape
The old simulation used one global
setInterval(..., 1000 / 15), walked every socket, built a fullsegmentarray containing player objects and snake arrays, then emitted that whole structure to every socket through Socket.IO. That means the hottest operation in the application asked JavaScript to repeatedly walk object graphs, serialize field names and numbers to text, pass them through Socket.IO/Engine.IO framing, allocate intermediary data, and make every browser parse the same verbosity back into objects.It also mixed mutation, collision handling, deletion, movement, and publication in one loop. After removing a player for a collision, the old loop could continue into
player.updatePosition()and append that player to the outgoing segment for the current tick. This is exactly the kind of lifecycle ambiguity that disappears when ownership and state transitions have to be written down explicitly.The new server uses:
writevbatching for coalesced HTTP pipelines;At 750 active lobbies, the final measured server required six game workers, not 750 lobby threads and certainly not two threads per client.
Why Zig is the right production language here
Again, “Zig fast, Node bad” is not a sufficient engineering argument. The useful question is what the language lets this particular server guarantee.
Deterministic data and ownership
The game has small, known caps and fixed wire formats. Zig lets those constraints exist as actual types, compile-time checks, stack arrays, bounded buffers, and explicit ownership instead of conventions spread across mutable JavaScript objects.
Examples:
u8wire fields;u16;These are executable invariants, not comments begging future code to behave.
Direct access to the Linux primitives we actually need
The production server is Linux-native and uses epoll. One reactor owns descriptors and connection I/O. Accepted sockets inherit
TCP_NODELAYfrom the listener. Final descriptor close performs the kernel's epoll cleanup, avoiding redundant per-connection syscalls. Ready sockets use direct nonblocking writes; retained output storage is used only when the peer actually applies backpressure.There is no libuv abstraction to negotiate with, no event-emitter layer in the hot path, and no Socket.IO protocol transforming one binary-capable transport into an event-shaped compatibility product.
A static deployment artifact
Zig produces one stripped, asset-embedded executable. The Dockerfile does not compile it.
start-docker.shbuilds on the host, verifies that the ELF has no dynamic interpreter, then copies only that executable intoscratch.The container runs:
65532;no-new-privileges;9687;No Node runtime. No npm. No shell. No package manager. No source tree. No
node_modules. No Pug compiler quietly meditating in production in case a 404 needs emotional support.Network protocol: stop mailing the same field names 15 times per second
The hot path no longer uses Socket.IO events or JSON snapshots.
Client input
Direction and visibility input use tiny fixed binary packets. Lobby join uses one bounded binary packet carrying lobby ID, username, and optional password lengths plus bytes. Parsers reject fragmentation, reserved bits, invalid UTF-8, noncanonical WebSocket lengths, malformed close frames, oversized payloads, partial application packets, and trailing garbage.
Snapshot v4
The server sends a compact, versioned binary snapshot:
u16exact;For the representative 16-player, 18-cell stream, snapshot v4 averages 29.40 bytes/frame, down from 34.40 bytes/frame in v3, a further 14.5% reduction. Against v2, direction deltas reduced moving-stream bytes by 47–49% for short snakes.
The older format-selection microbenchmark shows why the original JSON object stream had to go:
That is roughly 89.9% fewer repeated bytes, 93.6% less encode time, and 99.1% less parse time in that microbenchmark. The production v4 format is smaller still for common delta frames.
JSON remains where it belongs: infrequent control messages such as initialization, roster changes, errors, and activity feed entries.
/debug/statsuses Zig's typedstd.jsonserializer; manual serialization was measured and rejected when it did not justify its complexity.Measured production-shaped result
The final capacity run was measured on Linux, AMD Ryzen 7 5700G, Zig 0.16.0,
-O ReleaseFast -fstrip. Server and load clients ran over loopback. Sixteen Node load-generator processes opened the same raw WebSocket protocol as browsers and parsed every snapshot.At 12,000 concurrent players across 750 lobbies:
That is approximately 1,782 bytes of process RSS per connected player, including shared server state. Kernel socket memory and load-generator memory are not included.
These numbers are documented with methodology and caveats in
docs/BENCHMARKS.md. I am deliberately not inventing a fake current Node-versus-Zig percentage: the original Node stack was not rebuilt around the exact raw binary protocol and rerun side by side. The defensible claim is that the current Zig implementation meets the measured capacity above, while the old Node/Socket.IO/JSON architecture was structurally paying for work that the current protocol deletes.Additional measured optimizations
This was not one heroic rewrite followed by a victory lap. The implementation went through repeated measured passes.
HTTP parsing and output
The original incremental parser candidate compacted the receive buffer after every request, producing quadratic copies for large pipelines. The final parser advances an offset and compacts once; response segments drain in
writevbatches of up to 64.For 6,240 minimal pipelined requests:
That is 94.4% faster than the original parser shape, while preserving response order, bodies, partial trailing requests, HTTP/1.0, half-close behavior, and backpressure.
Lobby reaping
Ordered removal of 4,095 expired lobby entries shifted 8,382,465 later map entries—at least 191.9 MiB of key/value movement. Allocation-free swap removal reduced the measured reap from 14,712.030 us to 34.555 us, a 425.76x speedup.
Lobby player container
A capped 16-player lobby does not need a string hash map when the hot operations are stable iteration, append, and known-pointer removal. Replacing it with a pointer list reduced requested backing memory by 80.9%, made fill 3.49x faster, and made ordered remove/refill 5.88x faster.
Snapshot and tick scratch
Bounded stack arrays removed 22,500 arena-allocation API calls per second at 750 lobbies. Realtime sampling was hoisted from each lobby to each packed worker tick. The focused benchmark measured:
Connection churn
The server configures listener
TCP_NODELAYonce and relies on final close for epoll deregistration, removing onesetsockoptand oneepoll_ctlper connection: 24,000 syscalls across a 12,000-connection establish/teardown cycle.Maintenance reclaims poisoned, idle, and heartbeat-expired connections using fixed stack batches rather than allocating a temporary list under the exact pressure condition where allocation is least welcome.
Output queues
Backpressured queues now cap live bytes and item count, compact released descriptor prefixes, and release oversized idle descriptor arrays. Previously, a slowly progressing connection could retain consumed metadata indefinitely—roughly 31 MiB/day per connection at 15 snapshots/s even though the bytes themselves had already gone out.
Correctness and security work included
The migration is not “fast server, same bugs.” It adds or hardens:
nosniff, frame denial);Client and UX work
The server rewrite is accompanied by a complete client pass rather than leaving a fast backend attached to a 2019 scaffold.
GSAP was removed in favor of the Web Animations API. Startup JavaScript dropped by 58.4% in the measured graph at that stage, removing a 72,925-byte render-blocking dependency used for four transitions.
Repository cleanup
The repository is now honest about what it ships:
.idea/deployment.xmlis gone;Node still exists as an optional development/load-test tool because it is useful there. This PR is not a religious purge. It is a production-runtime eviction.
Verification
The final branch passes:
The benchmark suite additionally covers capacity, 4,000 idle sockets, malformed traffic, connection churn, paused readers, memory recovery, lobby lifecycle, snapshot encoding, HTTP pipelines, lobby reaping, worker balance, and player-container behavior.
Review guide
The easiest review order is:
README.md— architecture and how to run it.docs/SPEC.md— exact HTTP/WebSocket/game protocol and ownership rules.docs/BENCHMARKS.md— methodology, measurements, rejected ideas, and caveats.servers/zig/src/config.zig— centralized bounds and protocol constants.servers/zig/src/main.zig— reactor, lifecycle, HTTP, lobby/workers, and game loop.servers/zig/src/websocket.zig— RFC 6455 parser/writer.servers/zig/src/snapshot.zig— snapshot v4 encoder.servers/zig/src/collision.zigandmodel.zig— simulation data and collision behavior.client/js/transport.js,snapshot.js, andrendering.js— browser side of the protocol.start-docker.shandDockerfile— deployment boundary.Operational notes
9687.SNEK_*environment variables.SNEK_DEBUG=1exposes benchmark statistics and should be treated as an explicit operational choice.Final verdict on the old stack
The Node version was a perfectly recognizable prototype: quick to begin, easy to poke, and held together by enough npm packages to qualify for municipal funding. That is fine for proving the game exists.
It was not a good final production architecture for a multiplayer simulation whose dominant operation is “serialize and fan out bounded state 15 times per second.” Express was doing routing theater, Socket.IO was charging a compatibility tax, JSON was repeatedly mailing field names to browsers that already knew the schema, global maps were cosplaying as lobbies, and Pug was sitting in the corner compiling an error page with the confidence of a technology central to the product.
The Zig server does less. That is the point.
It allocates less, copies less, parses less, formats less, locks less, schedules fewer threads, performs fewer syscalls, sends fewer bytes, retains less state, and ships less software. It also now has the tests and measurements to prove where those claims are real—and the documentation is explicit where a comparison would not be apples-to-apples.
Please merge this before Pug notices we changed the locks.