Hey, Bastian here.

I’ve had this idea stuck in my head for a while, what if we could make the Prisoner’s Dilemma playable? Not just a classroom diagram, but an actual game where trust, betrayal, and reputation live in code.

So I started sketching. Then I opened Cursor. Then Phaser. Then I just… kept going.

What came out was Trustfall, a small experiment that somehow grew into a full 2D game running entirely in the browser, with real logic, persistent memory, and a reputation system that learns who you are over time.

Read the lore behind the factions and the world of Trustfall: trustfall.hoops.finance

Early Figma sketch of a Trustfall match

From Idea to Loop

The concept was simple: take the feeling of risk and cooperation from DeFi and make it visceral.

Players face off in a timed duel, Trust, Betray, or Run, and watch their choices ripple through their Score, Reputation, and Faction alignment.

Season 0’s goal was to prove the loop works: that a web-native stack can simulate complex trust dynamics, just logic, persistence, and polish.

The Iterated Prisoner’s Dilemma

At its core, Trustfall is a living simulation of the Prisoner’s Dilemma, the classic thought experiment where two players must decide whether to cooperate or betray, knowing that the best collective outcome only emerges if both choose trust.

Game theory says that rational players should always betray in a single round.

But repeat the game, and the logic flips: reputation starts to matter.

That’s where everything interesting happens.

In Trustfall, every match adds to your reputation, a 0–100 scale that doesn’t just track performance, it defines identity (and in the future, incentives)

Reputation decays or grows based on your behavior:

  • Mutual trust grants small, steady gains.

  • Betrayal spikes your short-term score but erodes your credibility.

  • Repeated selfishness traps you in the low end, where trust becomes nearly impossible to rebuild.

Those numbers decide your faction alignment:

  • Lumina Collective (60–100) – cooperators who default to Trust.

  • Shadow Syndicate (0–40) – opportunists who default to Betray.

  • Free Agents (41–59) – survivors who stay neutral.

That mapping turns simple math into personality.

Two players might start as equals but evolve into opposites. One slowly climbing toward the light, the other sinking into the shadows.

Then comes the twist: when no human opponent is found, the Arbiter steps in.

The Arbiter Order represents CPU players. They start pure, always trusting. But if in the previous match they are betrayed, they will remember. This is called a Tit-for-Tat strategy, and their memory is persistently stored in the database.

Rounds

1

2

3

4

5

6

n+1

Player

Trust

Betray

Trust

Trust

Betray

Trust

CPU

Trust

Trust

Betray

Trust

Trust

Betray

Its state updates after every match and survives across sessions, meaning it literally holds grudges.

As you can see in the table, the CPU always starts trusting, but if you Betray it, it will remember, and its next move will be Betray. However, the Arbiters are forgiving, so unless you keep betraying it, it will always go back to default and Trust.

I wanted it to feel less like an NPC and more like a silent observer, one that nudges the world toward balance. If everyone keeps betraying, the Arbiter becomes ruthless. If cooperation thrives, it softens again. That’s the loop that turns a simple binary choice into a social system.

Every click changes not only your score, but the behavior of the world watching you.

Toolchain & Workflow

Everything started on paper, quick notes, diagrams, and doodles of how trust might look if it were a mechanic instead of a theory.

From there, Cursor Agents helped me plan the architecture and logic, Phaser IDE became my playground for scene editing, Procreate handled the painted textures, and Aseprite gave life to the pixel animations.

That mix of analog sketching and AI-assisted tooling made it easy to jump between world-building and implementation.

Phaser handled all the rendering and timing; Next.js stitched it into a web-native flow; and SQLite quietly tracked everything behind the scenes, vault scores, reputation shifts, and the players’ memory, without needing any heavy backend.

Frontend Framework: Next.js (App Router)
Language: TypeScript (strict)
Game Engine: Phaser 4 (WebGL 2D)
Database: SQLite (local persistence)
Styling: Tailwind CSS
Backend: Next.js API Routes

Each match is generated through the API layer, persisted in SQLite, and rendered through a Phaser canvas mounted inside React components.

The stack looks overkill for a mini-game, but it let me test how far I can push modern frontend + local-DB interactions before porting the logic to Soroban.

Data Architecture

At the heart of Season 0 sits a clean little schema:

-- Players Table
id INTEGER PRIMARY KEY
username TEXT UNIQUE
wallet_id TEXT
vault_score INTEGER CHECK(vault_score >= 0)
reputation INTEGER CHECK(reputation BETWEEN 0 AND 100)
faction TEXT CHECK(faction IN ('lumina', 'shadow', 'none', 'arbitrer'))

-- Matches Table
id INTEGER PRIMARY KEY
player1_id INTEGER
player2_id INTEGER
player1_action TEXT
player2_action TEXT
player1_score_change INTEGER
player2_score_change INTEGER
player1_rep_change INTEGER
player2_rep_change INTEGER
match_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP

All data flows through a small REST API:

GET  /api/player/current
GET  /api/players/[username]
PUT  /api/players/[username]
POST /api/matches
GET  /api/matches
GET  /api/leaderboard
POST /api/reset-database

This structure let me replay, reset, and tune the scoring model quickly without touching front-end logic.

For example, the Reputation → Faction mapping lives in a helper that clamps rep between 0 and 100, then assigns:

if (rep >= 60) return 'lumina'
if (rep >= 41) return 'free'
return 'shadow'

That one line changes everything, from UI colors to default actions if a player times out.

Mechanics in Motion

The three factions of Trustfall, Lumina Collective, Shadow Syndicate, and the Arbiter Order

The game’s 60-second timer is managed by Phaser’s time.addEvent, color-coded as it ticks down.

Every button press triggers a local event that hits /api/matches, logs both actions, and updates the player’s record in real-time.

Each round references the payout matrix:

Players A ↓ / B →

Trust

Betray

Run

Trust

(+1, +1)

(-3, +3)

(+1,-1)

Betray

(+3, -3)

(-3,-3)

(+1,-1)

Run

(-1,+1)

(-1,+1)

(-1,+1)

Vault Score never drops below 0, this clamp makes sure you can lose reputation but never “die.”

It keeps gameplay forgiving enough for experimentation, especially against the Arbiter (CPU).

Now reputation is a bit different. Reputation actions are independent of the other player’s decision. This means, your reputation fully depends on your actions.

Choice

Reputation

Trust

+1

Betray

-1

Run

0

Now this is a very simple implementation of the reputation system. The way it goes up and down will change in the future based on your current faction, environmental factors, and a few match dynamics that I intend to implement.

Scenes & Systems

Each screen in Season 0 is its own Phaser Scene:

  1. Boot / Preloader – Loads assets (sprite sheets, fonts, parallax layers).

  2. Main Menu – Nine-layer parallax city with slowed-down cinematic scroll (0.05–0.6× speed).

  3. Profile – Real-time username editing with unsaved-changes warning.

  4. Matchmaking – CPU pairing + progress animation.

  5. Game – Timer, actions, HUD stats, dynamic reflections.

  6. Results – Tabbed interface showing match log + global leaderboard.

All the data passes through an event bus connecting React ↔ Phaser, so UI state and game state stay in sync.

Reflection animations run every 5 seconds just to make the world breathe.

Performance & Patterns

I learned a lot optimizing this one:

  • TileSprites keep scrolling layers lightweight. Super useful for the menu that has 9 layers of parallax effects.

  • Object pooling prevents sprite flicker when replaying matches.

  • Scene cleanup on transition avoids memory leaks (destroy timers + reset state).

  • Singleton pattern powers the Arbiter’s memory.

  • Observer pattern handles in-scene events like countdown updates or rep flashes.

Rough count: ~3,000 lines of TypeScript across 7 scenes.

Not massive, but dense enough to make Phaser feel like a full app framework.

Hoopy, Hoops Finance’s mascot, learning about TileSprites late at night

Why I Built It This Way

I could’ve hacked this together in Unity or Godot, which given its robust visual editing capabilities, would have made this probably easier for me, but keeping it web-native matters.

Everything here, from database calls to animation timing, runs inside a Next.js app.

That’s the same environment Hoops uses for its DeFi dashboards, so when we move to Season 1, connecting wallets, on-chain yield, and leaderboard NFTs will be seamless.

This project was never just about a game, it’s a testbed for web-native reputation systems, coordination mechanics, and eventually on-chain incentives.

Watch the video

If you want to see how all these tools came together, the transitions, the flickering vaults, and that moment when the first match actually runs, I recorded a short video of the build in motion.

What’s Next

Part 2 will focus on connecting this to real on-chain data:

  • Multiplayer through WebSockets

  • Wallet logins and signatures

  • Yield-driven scoring via the Hoops Router

  • Cloud DB migration (Postgres or Supabase)

  • AI improvements beyond Tit-for-Tat

For now, Season 0 proves that even a local MVP can make trust feel real.

Watching that little pixel vault open when both players choose “Trust” still gives me chills, maybe because it reminds me why we build decentralized systems in the first place.

Move fast, test your trust.

Bastian Koh

Keep Reading

No posts found