Daniel Wygoński
All work

Case study

Fuseball

A real-time multiplayer football game I first built in 2019 and rewrote from scratch in 2026, with an authoritative server, client-side prediction and a renderer that holds 60 fps on old phones.

Role
Solo, design, game code, servers and networking
Period
2019 - present
Stack
  • Bun
  • TypeScript
  • Canvas2D
  • Preact
  • WebSockets
  • Prisma
  • PostgreSQL
Links

Two versions, seven years apart

The first Fuseball (2019) was a React and p5.js game with WebSockets, accounts, player statistics, in-game chat and full mobile support. It is open source and picked up 23 stars and 9 forks on GitHub.

In 2026 I rewrote it from zero with three goals from day one: performance, game feel and ease of play. The target was an old Android phone at 60 fps, instantly playable, with progress that is saved.

Architecture

A Bun monorepo with three apps and shared packages. The key piece is one deterministic simulation that both the game server and the client run.

Client

Canvas2D rendererQuality tiers, cached pitch
shared step()Predicts your own player
Preact menusLobbies, account, stats

Game server

Bun.serve WebSocketBinary protocol, room pub/sub
shared step()Authoritative, 30 Hz
BotsFill matches, instant play

Central server

Bun HTTPAccounts, stats, news
Prisma + PostgreSQLPersistent progress
The same step(state, inputs, dt) function runs on both sides, covered by unit tests.

Key decisions

No framework in the game loop

Menus use Preact with signals, but the game itself renders with raw Canvas2D: no p5, no React, nothing between the loop and the canvas. The pitch is drawn once to an offscreen canvas and reused every frame. Two quality tiers, “potato” and “full”, switch effects like glow, trails and particles, and the game picks one automatically.

An authoritative server that still feels instant

The game server runs the one true simulation at 30 Hz. Your own player is predicted locally from your input, so movement has zero perceived latency, and when a server snapshot arrives the client rewinds to it and replays the inputs the server has not confirmed yet. Remote players are shown about 100 ms in the past and interpolated between snapshots, so they move smoothly.

The ball is the hard part. It is shared, so your client cannot fully predict it: it does not know that someone else is about to kick it. The client predicts it anyway and eases in server corrections through a decaying error offset, so corrections are hidden instead of snapping.

// Simplified illustration of reconciliation, not production code.
function onSnapshot(snap: Snapshot) {
  state = snap.state;                          // rewind to the server's truth
  pending = pending.filter((i) => i.seq > snap.lastAckedInput);
  for (const input of pending) state = step(state, [input], DT); // replay
}

A ball you can lift

The look is flat top-down, but the ball has a height value. Higher balls draw larger and their shadow shrinks and fades, which makes aerial passes readable without a 3D camera.

Play first, sign up later

Players get an anonymous account on their first visit and are treated as real users from then on. Signing in later converts that account and keeps all progress.

What I’d do next

I wrote up an input-delay lockstep model as a possible next step: every client runs the same deterministic simulation on the same inputs at the same ticks, so the ball is identical on every screen and nothing ever needs correcting. The trade-off is a small, constant input delay.