Inspiration

We are two people who waste twenty minutes every night on the same loop: "What do you want to do?" "I don't know, you choose." The decision is never hard. Agreeing is.

We wanted something that does two things dating apps already taught us are fun: throw options at you, and let a swipe be the whole answer. And for the times when swiping ends in a tie, we wanted a tiebreaker that is not a coin flip, because nobody accepts a coin flip. So we built a fight.

What it does

You write a dilemma, li tonight". Decidamosturns it into five concrete options using an LLM. Both people swipe through them, right for yes and left for no, either passing one phone back and forth or from two phones in the same room.

The app then shows what you matched on, what you almost matched on, and what you both rejected.

If you still can't agree, you fight for it. Each person writes the plan they are defending and picks an animal: bear, eagle, snake or turtle. Every animal has four abilities, an always-onThe fight is turn-based,with pixel art sprites, and the winner's plan is the plan.
Decidamos is free with ads, and a single one-time purchase removes them and unlocks every fighter.

How we built it

We started with a single-file HTML prototype to settle the flow before writing any app code, then ported it toypeScript.

The core rule was to keep the logic pure and separate from the screens:

  • battle.ts and sala.ts hold the rules and know nothing about React. They run in plain Node, so our tes.
  • The screens read state and call into that logic. Nothing more. The battle engine is de

$$\text{damage} = \frac{\text{power} \cdot (\text{atk}{\text{eff}} / \text{def}{\text{eff}}

where the effective attack and defence include the stat stages accumulated during the fight. The only randomness comes from a seeded PRNG, so the same seed and the same moves always produce the sames what lets two devicesstay in sync without sending a single message about damage: they send the room, not the outcome.

For the multiplayer side, one row per room in Supabase holds the whole room object as JSON, and Realtime pushes changes to the other phone. The host acts as the arbiter and resolves each round.

For testing we drive the app in a browser with Playwright, because Expo can serve the same React Native code on the web. One script plays a full match and a full fight, start to finish, and fails if

Monetization is RevenueCat over Google Play Billing, with a mock mode that takes over whenever the native module or the API key is missing, so the app still runs in Expo Go and on the web.

Challenges we ran into

Balancing the fighters. Tuning by hand made things worse every time: one animal would win 95% of its matchupshat simulates thousandsof fights, but our first version pushed every animal toward the same stat line and erased what made them different. The fix was to fix health by character identity and only tune attack. Win rates now sit betwe

Two phones, one infinite loop. Our first sync published the room on every change. A change arriving from the network counted as a change, so it published back, which arrived again, which published a saturated channel andlost events. The fix was to mark which changes came from the network and to recognize our own echo.

A bug that looked like cheating. On a single phone, handing the device over remounts the fight screen, and it replayed the previous round: animations, shake and all. The player who had already layed a move for them. We now track which round each player has already seen, outside the screen so it survives the remount.

Shipping to a store is its own project. Our first release build ran in mock mode and gave Pro away for free, because the keys are baked in at build time and that build was made without them. The lently because theRevenueCat dashboard had the product registered under a test store instead of Google Play. We found both by reading the release bundle and the phone's own logs. **The AI went down mid- retired, anotherreturned empty content and a third was rate limited. Our fallback chain only retried on rate limits, so a retired model killed the whole cascade. It now retries on every recoverable error, times out per attempt, g brackets instead oftrusting the model to answer cleanly.

Accomplishments that we're proud of

  • A real purchase went d, and we can see it inRevenueCat.
  • The fight engine is deterministic and covered by tests that run 300 fights and check every one of them ends with exactly one winner.
  • Fighters are balanced without being identical.
  • The whole app is in Sh the code and commentsin Spanish too.
  • We caught our worst bugs with tests that drive the real app, not with mockthat agree with us. ## What we learned

  • Environment variables that start with EXPO_PUBLIC_ are baked into the bundle when it is built. A key that is missing at build time is missing forever in that binary, and no dashboard will fix it afterwards.

  • A pure logic layer is worth the discipline. Every hard bug we hit was reproducible in Node in under a second.

  • When the truth lives on two devices, send state and reconcile it. Sending events means inventing a protocol,

  • An error that fails silently costs more than a crash. Most of our lost hours were spent on buttons that did nothing.

What's next for Decidamos

  • Accounts on the server, so a profile and a purchase follow you to a new phone. Today they live on the device.
  • Locking down the rooms table and expiring old rooms.
  • Real ad units, replacing Google's test identifiers.
  • Rematches across distance, and a history of the decisions a couple has made together.

Built With

Share this project:

Updates

Submission history