PaySim Town
showcase · not publicly hosted
Java 21 · Spring Boot 4 · SQLite · pixel town

A payment gateway
you can watch fail on purpose.

PaySim Town is a real HTTP-idempotent payment gateway and a digital bank with holds, capture and refund — double-entry ledgers per store, a hand-rolled circuit breaker, and a crash injector that can kill the process mid-payment — wrapped in a pixel-art town where customers walk to shops, envelopes cross a bridge to the bank, and a storm cloud forms when the breaker opens.

The point isn’t the game. It’s that nothing here ever double-charges, loses money, or lies about what happened — even when the bank is offline or the JVM dies between two writes. Every animation on screen is driven by a server-sent event; the frontend holds no business state of its own.

PaySim Town's pixel-art map: shops, a bridge over a river, and the digital bank, with a live control deck and event log around the edges.
Market Town — shops, customers and the bank, rendered purely from the server’s event stream.
What it actually is

An engineering study of payment failure handling

Underneath the pixel art is a Java 21 + Spring Boot 4 service with a real gateway/bank split over loopback HTTP — not an in-process shortcut — so a timeout is a genuine socket timeout, and the gateway’s outbound call and the bank’s inbound handling get separate trace spans, the way a production gateway-to-acquirer call actually looks.

01 / idempotency

Every mutating call is replay-safe

Every mutating endpoint requires an Idempotency-Key. The same key and body replay the stored response byte-for-byte; a different body with the same key is rejected; a key still in flight gets a retry hint instead of a second charge.

02 / downstream

Deterministic keys to the bank

Every gateway→bank call carries a key derived from the payment itself, never a fresh UUID per attempt — so retries, lease takeovers and reconciler re-drives all hit the bank as the exact same operation.

03 / ledgers

Double-entry, append-only, per store

Every business event is a balanced journal entry — postings must sum to Σ = 0 — and that invariant is unrepresentable to break in memory, not just checked at write time.

04 / resilience

A hand-rolled circuit breaker

Closed, open and half-open states live in a sealed interface with CAS transitions and no lock, each guarded by a generation so a stale probe can never record an outcome against a breaker that has already moved on.

05 / chaos

A crash injector that kills the JVM

A simulated crash can be thrown at named points inside the payment and ledger flow — after the bank approves but before the gateway records it, for example — and every one of those windows is provably recoverable.

06 / recovery

A timeout is an unknown outcome

If the bank call times out, the payment is never guessed into failed. It stays in-flight until a reconciler asks the bank what actually happened and resolves it from the answer, not from an assumption.

Scenario

Watch the bank go down

A bank-outage scenario drives every mutating call toward a sliding-window failure rate. Once it crosses the threshold, the breaker opens.

The bank-outage scenario: the breaker pill reads OPEN, event log shows CIRCUIT_OPEN retries, and storm clouds sit over the bank building in the pixel town.
Bank outage — breaker open, storm over the bank, toll arm down.
breaker: open new calls fail fast no attempt wasted

Retry sits outside the breaker: each attempt counts toward its failure window, and once it’s open, new payments fail fast in milliseconds instead of waiting out a retry budget. A half-open probe tests the water with a capped number of requests before the breaker decides whether to close again.

Backoff between attempts uses full jitter across the whole window, so retrying customers desynchronize instead of hammering the bank in lockstep the moment it recovers.

Scenario

Send it twice on purpose

The Inspector narrates one payment end to end, from the customer’s counter to the bank and back, with every step annotated by the event that produced it.

The payment inspector panel showing a captured $12.40 payment, its idempotency key, and a timeline of steps from customer authorization to gateway duplicate-shield checks.
Payment inspector — one payment’s whole journey, narrated from real events.
captured duplicate shield risk checks in parallel

A duplicate submission with the same idempotency key never reaches the bank a second time — the gateway claims the key once, and the second request gets the first request’s stored response back, byte for byte.

Three independent risk checks — fraud score, customer velocity, store ticket limit — run concurrently with one shared deadline, so a slow check can’t silently extend how long a customer waits at the counter.

Under the hood

Built to lean on the JDK, not route around it

Every customer, every SSE writer and the ledger poster run on their own virtual thread. Mutual exclusion goes through ReentrantLock, never synchronized, because a pinned virtual thread on JDK 21 defeats the point of having thousands of cheap ones.

Java 21 Spring Boot 4.1.1 Spring Framework 7 Virtual threads StructuredTaskScope ScopedValue SQLite · WAL Transactional outbox Server-sent events Sealed state machines pixi.js town renderer
● This project runs locally — make demo builds the frontend and starts the Java service on localhost:8080. It isn’t deployed anywhere public; this page exists to show what it does.