Repository navigation
Architecture
crates/ntba-core platform-independent game: simulation + software renderer (no deps)
crates/ntba-net online play: wire protocol, transport trait, lock-step session (no deps but ntba-core)
crates/ntba-wasm cdylib exposing ntba-core and ntba-net through a plain C ABI
web/ static host page: title menu, canvas, keyboard, fixed-timestep loop, WebRTC, lobby
signaling/ Cloudflare Worker + Durable Objects (rooms, lobby) for online play (see Online Play)
scripts/ build-web.sh, serve.sh, determinism and wasm net checks
keyboard ─▶ web/main.js ─ buttons bitmask ─▶ ntba_step() ─▶ Game::step (60 Hz fixed tick)
canvas ◀─ RGBA bytes ◀── ntba_framebuffer() ◀ ntba_render() ◀ render::draw (once per frame)
- The host owns time. It accumulates real elapsed time and calls
ntba_steponce per 1/60 s, then renders once per animation frame. The core never reads a clock. - Input is a
u8bitmask (ntba_core::input). That keeps replays, tests and a future lock-step P2P protocol (exchange one byte per tick) trivial. - The core renders into a 320x200 paletted framebuffer (see Period constraints below) and
expands it to RGBA. The page scales the canvas with CSS
image-rendering: pixelated. - The wasm module uses a C ABI, not
wasm-bindgen, so the only build requirement is thewasm32-unknown-unknowntarget. Keep the exported surface tiny (ntba-wasm/src/lib.rs).
Online play reuses the same loop: instead of ntba_step, the host calls ntba_net_step, which
runs a lock-step session (crates/ntba-net) that exchanges input bytes with the other peer over
WebRTC. See Online Play.
| Module | Role |
|---|---|
main.js |
Page wiring, keyboard, the fixed-timestep loop (local or lock-step), the wasm glue. |
flow.js |
Pure screen state machine: title, local, online, match. |
net.js |
Room signalling and the match RTCPeerConnection (two data channels). |
lobby.js |
Lobby client: presence socket, WebRTC mesh to the other players, chat and challenge messages. |
lobby-protocol.js |
Pure helpers: name and chat cleaning, peer-message validation, the offer rule, rate limiter, id generation. |
challenge.js |
Pure challenge reducer (state and effects). |
chat.js, lobby-ui.js
|
Chat log and player list; text is inserted as text, never HTML. |
The pure modules (flow, lobby-protocol, challenge, and the lobby client with fake peers) have
Node tests in web/test, run with node --test web/test/*.test.js.
The last thing render::draw does is draw a small "DEVELOPMENT VERSION" plate flush with the
top-right corner of the framebuffer. It uses the game's own font and palette and is purely a
rendering overlay, outside the simulation, so it never affects game state or hashes.
We deliberately work within 1990 PC limits, which is what the reference games used:
- 320x200, like VGA mode 13h (the Amiga/DOS screenshots we studied are all 320x200). The page displays it at 4:3, as CRTs did, so pixels are slightly taller than wide.
-
Palette of at most 256 colours (
palette::MAX_COLOURS), 6 bits per channel (palette::vga). We use fewer than 100, authored as 4-tone ramps per material (dark, shadow, base, light) so shapes are shaded by hand-picking tones rather than by blending. - No true colour, alpha or blending: shadows darken by remapping palette indices, soft edges and
lighting use ordered dithering (
render::bayer), as period games did. - A test (
palette::tests,render::tests) enforces the palette limit and that frames stay inside it.
sprite.rs is a tiny paletted-canvas toolkit: shaded ellipses and capsules lit from the top-left,
text, and a selective outline (every edge takes the darkest tone of the colour it borders).
people.rs builds the figures from it: a skeleton-driven run cycle with forward kinematics for the
legs and two-bone IK so the dribbling arm follows the ball, front/back versions for the end-on
camera, and seated bench figures. Spectators in the crowd use the same toolkit at smaller scales.
Everything is procedural (generated in code) today, not hand-drawn sprite art. Replacing it
with hand-authored sprite sheets in the same palette is a drop-in change inside those two
modules; the simulation and scene code only ask for "a person with this pose".
The simulation owns a View (Side or End) because the view changes what the direction keys
mean. The ball position drives it with hysteresis (END_VIEW_ENTER_X, END_VIEW_EXIT_X in
game.rs): in the frontcourt the game cuts to the end-on camera (looking at the basket from
midcourt, render::project_end), and back to the side camera in transition. In the end-on view
the direction keys are screen-relative: Up runs toward the basket, Left/Right strafe. A direction
held across a cut keeps its old meaning until released, so the cut never reverses a run mid-stride.
The renderer picks sprites (people::side_person / people::end_person) and projections from
Game::view.
- The camera scrolls horizontally to follow the ball (
Framebuffer::update_camera, visual only, eased per rendered frame, clamped so one end of the court is always in view). The crowd scrolls slower than the floor for parallax. -
render::project(cam_x, x, y, z)maps feet to pixels. Scale grows linearly fromFAR_SCALEtoNEAR_SCALEpx/ft between the far and near sidelines, which gives the gently slanted baselines of the originals; the floor is drawn by inverting that mapping per pixel (draw_court). - Hardwood planks, lines and lane colours are computed from world coordinates, so they stay put while the camera moves; lighting and dithering are applied per screen pixel.
- The player you control wears black shoes, as in the Genesis manual.
- Fixed timestep, no wall-clock, no threads. Randomness comes from the seeded xorshift in
Game. - Stick to
+ - * /andsqrtonf32insidegame.rs; avoidsin/cos/powfthere. These are the operations that stay bit-identical across machines, which matters for P2P lock-step. Visual-only code inrender.rsis free of this restriction. - Court units are feet: x is baseline-to-baseline (0..94), y sideline-to-sideline (0..50),
z is height.
render::projectmaps these to screen pixels.
One team of five attacks the right-hand basket with no defence. Arrow keys / WASD move the
controlled player; X shoots (2 or 3 points by distance; chance falls with distance); Z passes
to the teammate best aligned with the held direction (or the nearest if none is held); C /
Space switches player when the ball is loose. Misses bounce off the rim and a teammate recovers
them. Shot clock 24 s, one 5-minute period, Enter restarts after the buzzer.
-
Opposition / bot AI: a
controllerper player (human, bot, remote) producing the sameu8input mask. Online play already works at the team-pad level (ntba-net); a controller abstraction would let a bot or a second local player share a team. -
Sprites: player/ball/referee drawing in
render.rsis procedural today; swap for hand-authored sprite sheets (indexed pixels in the same 32-colour palette) without touching the simulation. - Audio: the host will play sounds from event flags the core exposes (not implemented yet).