Cards4.net

What We Learned Building Cards4.net

By H. Marcell · 10 min Reading time

Table of contents

What We Learned Building Cards4.net

We launched Cards4.net quietly. No Product Hunt post, no press release, no launch-day Twitter thread. We pushed the deploy script, watched the logs, and played a few games of Klondike to make sure nothing was on fire.

That low-key launch was partly intentional and partly a reflection of how the project developed — slowly, in spare hours, without a deadline or a funding round forcing a date. But now that the core product is shipped and stable, it feels worth writing down what we learned. Not as a victory lap, but as an honest account of the decisions we made, the ones that worked, and the ones we'd make differently.

Quick Summary

The biggest product lesson was that a quiet card game site benefits from boring technical choices and strong boundaries. Phoenix and PostgreSQL made the backend reliable. React and Vite made the board interactions practical. Server-authoritative state kept saves, hints, undo, and leaderboards consistent. The no-ads decision kept the product incentives clean.

If you only want to play, start with Klondike Solitaire, FreeCell, or Spider Solitaire. If you are here for the engineering notes, keep reading.

Tech Stack Choices

The backend is Phoenix and Elixir. The frontend is React with Vite and TypeScript. The database is PostgreSQL. We're hosted on Hetzner.

None of these are surprising choices for a small web application in 2026, but each one was a deliberate decision rather than a default.

Phoenix and Elixir were chosen because the game server model maps naturally onto Elixir's process model. Each active game session is a GenServer process. The supervisor tree handles crashes gracefully — a game session that crashes doesn't take down other sessions, and the supervisor restarts it with the last saved state. We haven't needed to think about this in production because it just works, which is exactly what you want from infrastructure.

The other reason for Phoenix is the Plug pipeline. Our auth and rate-limiting logic is a chain of plugs, each doing one thing. Adding a new rate limit or a new auth check means adding a plug to the pipeline. The code is easy to read, easy to test, and easy to reason about. We've never had a security incident where we thought "I wish this were more complex."

React and Vite were chosen because the frontend team (one person) already knew them well. We considered HTMX for a while — the server-authoritative game state would have mapped reasonably onto HTMX's model — but the drag-and-drop requirements for the card games pushed us toward a richer client. HTMX doesn't have a great story for complex pointer event handling. React does.

Vite's build speed is genuinely pleasant. Cold builds take under 10 seconds. Hot module replacement is fast enough that we stopped noticing it. We've had zero Vite-related production incidents.

Hetzner was chosen over AWS or GCP because the cost difference is significant at small scale and the operational complexity is lower. We run a single app container plus a Postgres sidecar. The nginx reverse proxy and Let's Encrypt cert management live in a separate hostconfig repo. The whole setup fits in one person's head, which matters when that person is also writing the application code.

The one thing we'd reconsider: we're running a single app container with no horizontal scaling. This is fine for current traffic, but it means deploys have a brief downtime window. We've implemented a rolling deploy script that minimizes this, but it's not zero. A proper load balancer with two containers would eliminate it. We'll get there.

The Server-Authoritative Decision

Early in the project, we made a decision that shaped everything else: game state lives on the server, not the client. The client sends move requests; the server validates them, applies them, and returns the canonical state. The client renders what the server says.

This was the right call, and we'd make it again without hesitation.

The alternative — client-authoritative state with server sync — is tempting because it feels faster. The client can apply moves immediately without waiting for a round trip. But client-authoritative state creates a category of bugs that are genuinely hard to fix: state divergence. If the client and server disagree about the game state, which one is right? How do you reconcile them? How do you prevent cheating?

With server-authoritative state, these questions don't arise. The server is always right. If the client shows something different from what the server says, the client is wrong and should update. The leaderboard times are trustworthy because the server recorded every move and its timestamp. The hint system works correctly because it operates on the canonical state. The undo system is simple because the server maintains the history.

The latency cost is real but smaller than we expected. On a well-engineered backend with a fast database, a move validation round trip takes 20 to 50 milliseconds. That's imperceptible for a card game. We added optimistic UI updates for the drag-and-drop feel — the card moves visually before the server confirms — with a snap-back animation if the server rejects the move. This gives the responsiveness of client-side state with the correctness of server-side authority.

The one place this decision created friction was the hint system. Computing hints requires examining the full game state and running a search algorithm. On the server, this is fine — we have the state and the compute. But it means hints have a round-trip latency that's slightly more noticeable than move latency. We've optimized the hint algorithm significantly, but there's a ceiling on how fast a server-side hint can feel.

"No Ads, No Tracking" Posture

We decided before writing a line of code that Cards4.net would never show ads and would never install third-party tracking. This wasn't a marketing decision — it was a product decision that shaped everything downstream.

The most immediate effect was on analytics. We use Plausible Analytics, a privacy-first European service that counts page views without tracking individuals. We ask for consent before loading even Plausible. If you decline, nothing loads. This means our analytics data is incomplete — we know roughly how many people visit, but we don't know who they are, where they came from, or what they did before arriving.

That's fine. We don't need to know. The questions we actually care about — is the site up, are people playing games, are there errors — are answerable with the data we have.

The harder consequence was on growth. Ad-supported card game sites can buy traffic. We can't. We grow through organic search and word of mouth, which is slower. We've accepted this trade-off, but it's worth being honest that it's a real constraint. We're not growing as fast as we would if we ran ads.

The unexpected benefit was on product focus. When you can't monetize attention, you stop optimizing for attention. We've never had a conversation about making the game slightly more frustrating to increase session length. We've never A/B tested a dark pattern. Every product decision has been about making the games better, because that's the only lever we have. It turns out this is a pleasant way to build software.

What We Wish We'd Done Sooner

Written tests from the start. We have good test coverage now — above 90% on the backend, above 80% on the frontend — but we got there by retrofitting tests onto code that was already written. Writing tests after the fact is harder than writing them alongside the code. The game engine in particular would have benefited from test-driven development; there were several subtle bugs in the move validation logic that tests would have caught earlier.

Set up the seed library earlier. The pre-validated solvable seed library — the pool of confirmed-winnable deals that powers daily challenges and solvable mode — took significant compute time to generate. We started generating it later than we should have, which delayed the daily challenge feature. If we were starting over, we'd run the solver as soon as the game rules were stable, not after the full product was built.

Documented the architecture sooner. The AGENTS.md file that describes the codebase for AI assistants and new contributors didn't exist until well into the project. Writing it forced us to articulate decisions we'd made implicitly, and several of those articulations revealed inconsistencies we then fixed. Documentation as a forcing function for clarity is underrated.

Picked a deployment story earlier. We spent time early in the project with an ad-hoc deployment process that worked but wasn't reproducible. The current deploy script — which validates the environment, builds the Docker image, runs migrations, and does a rolling restart — took a day to write and has saved many hours since. We should have written it sooner.

What's Next

The single-player games are stable. Multiplayer Hearts, Spades, and Euchre are live enough to have routes and leaderboards, but they still have room to grow. The content hub — guides and blog articles — is shipping now. Daily challenges are live.

The honest answer about what's next is: we're not sure of the exact order. We have a list of things we want to build and a rough priority, but the project is small enough that priorities shift based on what's interesting and what users are asking for. We'd rather ship things we're proud of on a flexible timeline than ship things we're not proud of on a fixed one.

What we're confident about: Cards4.net will stay ad-free, stay privacy-respecting, and keep getting better at the thing it's actually for — being a good place to play card games.


FAQ

Q: Why Phoenix and Elixir instead of Node.js or Rails?

Elixir's process model is a genuinely good fit for game servers. Each game session is a supervised process; crashes are isolated and recoverable. The Plug pipeline makes auth and rate-limiting composable and testable. We also find Elixir pleasant to write — pattern matching and the pipe operator make game logic readable in a way that's harder to achieve in JavaScript or Ruby. That said, Phoenix has a steeper learning curve than Rails, and if the team didn't already know it, we might have chosen differently.

Q: How much does it cost to run Cards4.net?

We don't publish exact numbers, but the infrastructure is modest. A Hetzner VPS, a managed Postgres instance, Plausible Analytics, and Sentry for error tracking. The total is well under $100/month at current traffic. The main cost is developer time, which is donated. This is sustainable at current scale; it would need to change if traffic grew by an order of magnitude.

Q: Will you open-source the codebase?

We haven't decided. Parts of the codebase — the game engine, the solver — might be useful to others and are reasonable candidates for open-sourcing. The full application stack includes infrastructure-specific configuration that would need to be cleaned up first. It's on the list of things to consider, but it's not imminent.

Q: How do you handle the magic-link auth without a traditional password system?

Magic links are email-based one-time tokens. You enter your email, we send a link, you click it, you're signed in. The token expires after a short window and can only be used once. There are no passwords to store, no password reset flows, no "forgot password" emails. The security model is: if you control your email, you control your account. This is simpler to implement correctly than password auth and eliminates an entire category of credential-stuffing attacks.

Q: What would you do differently if you were starting over today?

Tests from day one, seed library generation started earlier, and a deploy script written in week one rather than month four. Beyond those specifics: we'd probably write more documentation earlier. The discipline of writing down why you made a decision, not just what you decided, pays dividends when you come back to the code six months later and can't remember why something is the way it is.


FAQ

What tech stack does Cards4.net use?

Cards4.net uses a Phoenix and Elixir backend, PostgreSQL, and a React/Vite/TypeScript frontend.

Why is game state server-authoritative?

Server-authoritative state keeps move validation, saves, hints, undo, and leaderboards consistent and harder to tamper with.

Does Cards4.net use ads or third-party tracking?

No. Cards4.net is built around an ad-free, privacy-first product model.

What would the team do differently?

Write tests earlier, generate solvable seed libraries earlier, document architecture sooner, and settle deployment earlier.

See also