Inspiration
For years I wanted to participate in a hackathon. Time was always my main excuse. This year I finally had the space to try.
Picking an idea was genuinely the hardest part. I sat with it longer than expected. Eventually I landed on something I'd been carrying around half-formed: a relaxing game where every person who joins becomes a raindrop, and the world itself (rivers, clouds, lakes, snowfields) only exists because enough people chose to be there. No scores. No loss. The environment is just a live reflection of how many people showed up and what they chose to be.
The water cycle became the metaphor: liquid droplets flowing together form a river; enough vapor rising coalesces into a cloud; ice crystallizing becomes a snowfield. The world is peaceful, ADHD-friendly, and designed for scale. It starts empty. It grows as people arrive.
I knew it was ambitious given the time I had. I decided that was fine. An ambitious idea that's 70% finished teaches you more than a safe one that's 100% finished.
What it does
RaindropWorld is a real-time multiplayer game where every authenticated user is a raindrop in a shared 3D world. You choose your state — liquid, vapor, ice, or frozen — and move through an infinite procedurally generated terrain. Your presence, combined with every other user's, shapes the world:
- Liquid droplets flowing together form puddles → rivers → lakes
- Vapor rising coalesces into clouds
- Ice crystallizing creates snowfields
The world state updates in real time via WebSocket subscriptions. Zone transitions (e.g. "a river has formed!") appear as toast notifications. The terrain's visual maturity evolves: the world starts as a barren desert and progresses through humid wetlands, flowing rivers, flourishing lakes, and finally a thriving full water-cycle ecosystem with rain and lightning.
A bot simulator can populate the world for demo purposes — 50 bots spawn per minute with random states and positions, auto-expiring after 90 seconds via DynamoDB TTL.
How I built it
Frontend — Next.js 16 with React Three Fiber rendering an infinite procedural terrain (simplex noise for rolling hills). v0 scaffolded the UI components (state selector, world stats panel, join screen, event toast), which were then iterated on heavily in Gemini — fixing bugs like state changes not reflecting visually, GPU-heavy particle systems, and zones that never changed regardless of actual user counts. A useWorldState hook subscribes to AppSync WebSockets and drives everything from particle counts to terrain maturity crossfades. Player movement uses WASD/click-to-move with smooth camera lerp.
Backend — The DynamoDB single-table design and AppSync GraphQL schema were designed with Claude, focusing on defensible engineering decisions. A single DynamoDB Global Table replicated across us-east-1, eu-west-1, and ap-southeast-1. The key insight: GSI1SK = STATE#{state} as the index key. Every droplet's state is encoded in its GSI sort key, so counting droplets by state is a simple Query(Select: COUNT) with zero scan cost — millisecond latency even at millions of droplets.
Stress-testing the architecture — Gemini was used to pressure-test where the design breaks at scale. The honest answer: a single DynamoDB partition handling all zone writes caps out at around 10,000 concurrent users. That number, and the geospatial sharding fix that followed from it, became a real part of the project's story.
The aggregation pipeline:
- User calls
joinWorld/changeState→ Lambda writes to DynamoDB - DynamoDB Streams captures the change (2s batching window)
- Aggregation Lambda fires — recomputes all counts from GSI (never does delta math, avoiding drift entirely)
- Writes the zone SUMMARY item with derived zone type, then calls
broadcastWorldStatevia a signed IAM request to AppSync - AppSync fans out the update to every connected WebSocket subscriber
Infrastructure — Everything is CloudFormation: Cognito user pools, AppSync schema + resolvers, Lambda functions with DynamoDB Stream event source mappings, EventBridge scheduler for the bot simulator, DynamoDB Global Table with streams. One deploy command provisions the entire stack.
3D rendering — React Three Fiber with an InfiniteTerrain component, SceneEnvironment for sky/fog/lighting, WorldElements for zone-specific visuals (river meshes, lake surfaces, cloud particles), CameraController for player movement and region detection, and OtherDroplets for rendering other users as colored spheres.
Challenges I ran into
The tools you start with aren't the tools you'll finish with. My original plan was to build the entire frontend in v0 end-to-end. I burned through my v0 credits prompt by prompt and ended up with something that worked but didn't feel right — state changes weren't visually obvious, performance dropped once the cloud layer got dense. I switched to Gemini to push the implementation further: refining the rendering, fixing real bugs, and making the world genuinely responsive to live data instead of static mock zones. By the final days I'd hit free-tier limits on both Claude and Gemini. I had one rule for myself: no spending beyond the AWS and v0 credits the hackathon provided. So for final adjustments I moved to OpenCode. Four different AI assistants across one project — not because I planned it, but because switching was cheaper than getting stuck.
The subscription bug that took hours to find. The broadcastWorldState mutation used a NoneDataSource (passthrough) resolver. The request VTL returned $ctx.arguments — which wraps the input in { input: { ... } }. Subscribers received { zoneType: null } because the WorldState fields were nested one level too deep. Fix: $ctx.arguments.input instead of $ctx.arguments. But that alone wasn't enough — the WorldState type inherited Cognito-only auth by default, so the IAM-authenticated Lambda calling the mutation got "Not Authorized" on every response field. Adding @aws_iam @aws_cognito_user_pools to the type directives fixed it.
DynamoDB Global Table stream debugging. The event source mapping's filter criteria uses prefix matching on PK.S. I initially misconfigured the filter and the aggregation Lambda silently never fired for bot drops. Tracing through DescribeStream, shard iterators, and CloudWatch metrics taught us more about DynamoDB Streams internals than I expected.
Coordinate system mismatch. The bot simulator stores 3D positions directly (−30 to 30), while real users' positions pass through a threeToWorld conversion giving 0–3000 range. The frontend's rendering component uses a |coord| > 200 heuristic to detect which format a droplet uses.
Event type naming mismatch. The backend emits events as ${zoneType}_formed (e.g. lake_formed), but the frontend's EventType union had lake_filled. This caused EVENT_MESSAGES[eventType] to return undefined, and calling undefined(dropletCount) threw a runtime error that crashed the toast component.
CloudFormation managed hook failures. The AWS::EarlyValidation::ResourceExistenceCheck hook blocked deployment of resolver updates. I had to apply some resolver changes directly via the AppSync API while keeping the template updated for future full deployments.
Accomplishments that we're proud of
- Finishing under self-imposed constraints: no personal money spent on tooling, only the hackathon-provided AWS and v0 credits
- The entire infrastructure — auth, API, database, compute, real-time pub/sub, and 3D rendering — is deployed with a single CloudFormation template
- Recomputing droplet counts from the GSI on every aggregation event, ensuring perfect accuracy at any scale with no drift. A defensible engineering decision, not just a functional one
- Stress-testing the architecture honestly: identifying the 10,000 concurrent user bottleneck and designing the geospatial sharding fix before it became a production problem
- A 3D infinite terrain rendered in the browser with procedural biomes that react to live user data
- Real-time WebSocket updates that push zone transitions to all connected clients within seconds
- The bot simulator with auto-expiring TTL droplets — perfect for demo without manual cleanup
What I learned
The tools you start with aren't the tools you'll finish with. I used four different AI assistants across this project — v0, Gemini, Claude, and OpenCode — not because I planned to, but because each one ran out of usefulness (or credits) at a different point. Switching was cheaper than getting stuck.
Recompute beats delta. Counting from GSI on every aggregation event is wasteful at small scale, but at million-droplet scale it's the only safe pattern. Delta math drifts over time — a missed stream event, a failed Lambda invocation, a TTL expiry skipping the stream — and once drift happens you can never recover. Counting from the index is idempotent by design.
DynamoDB single-table design is a superpower. One table, four access patterns (droplet by user, droplets by state, zone summary, world events). No joins, no transactions for reads, millisecond latency at any scale. The GSI with KEYS_ONLY projection keeps index cost near zero.
AppSync subscriptions need three things to work:
- A mutation that returns the correct type data
- The mutation resolver must produce clean output (no extra wrappers)
- The response type must authorize the mutation's auth mode
Miss any one and the subscription silently delivers nothing.
CloudFormation is great for reproducibility but painful for iteration. Managed hooks, change set validation, and resource timing issues made rapid iteration difficult. A hybrid approach — CloudFormation for foundation, direct API calls for hotfixes — worked well for us.
What's next for RaindropWorld
State-specific mobility. If you're liquid, you should flow as part of a puddle, drift through rivers, spread across lakes, and eventually ride ocean currents — discovering fish, hidden eddies, and ecosystem pockets along the way. If you're vapor, you rise and ride wind currents between cloud banks. If you're ice or frozen, you drift slowly, catching on banks and melting as the world warms. Each state should fundamentally change how you navigate the world.
Beautify everything. More flowers, more wildlife, more reasons to stop and watch. The world is a journey, not a race — the visuals should reward lingering.
Day/night cycle and dynamic map. A clock drives transitions between dawn, day, dusk, and night, each changing the terrain's mood, colors, and ambient behavior. Night might reveal bioluminescent creatures; dawn might burn off the mist. The map evolves with the time of day, not just the droplet counts.
Built With
- amazon-dynamodb
- amazon-web-services
- eventbridge
- lambda
- nextjs
- node.js
- react
- v0
- vercel
Log in or sign up for Devpost to join the conversation.