HomeWorkWhat I doContact
All projects

Moment Capture case study

Making a shared pile of photos feel real

Moment Capture drops each photo into a box on your screen, where it falls, tumbles and settles, and everyone in a shared box gets it in their own pile.

Role
Flutter app and Go backend
Platforms
iOS, Android
Stack
Flutter, Go, PostgreSQL, Docker
Try it
Moment Capture App Store preview: every photo drops into the box like a real print
Moment Capture App Store preview: a box shared with your people
Moment Capture App Store preview: invite to a box with a link

The problem

Real feel, shared trust

Rigid bodies in a flat 2D world stack into towers, but printed photos on a table overlap and stay low. The simulation also has to survive a dropped frame, let a finger pull one photo out of the middle of the pile and stop using the CPU once everything is at rest.

A shared box adds other people and unreliable phones. Several members write to the same box, connections drop without warning, and people need to report photos and block each other without a block deleting a photo the rest of the group still wants.

Architecture

Local physics, a server that says pull

The physics runs entirely on the phone. The shared-box feed carries no positions, so each phone settles its own pile and saves resting poses as proportions of the screen width, ready to restore on a different layout.

Sharing is built around one ordered change feed per box. A photo goes straight to object storage through a presigned URL, then the server checks its size and gives it a sequence number under a per-box lock. Live events only tell open apps to read that feed.

  1. Physics

    Forge2D on Flutter

    Fixed-step simulation of rigid photos

  2. Sync

    Dart sync engine

    Sends deletes, uploads and stars, then pulls the feed

  3. Live signal

    Server-Sent Events

    Tells open apps that something changed

  4. API

    Go

    Membership checks, per-box locks, ordered feed

  5. Storage

    PostgreSQL, Cloudflare R2

    Rows and tombstones in Postgres, files in R2

Key decisions

Three decisions behind the box

01

Depth layers instead of stacking

If every photo collides with every other photo, a 2D pile becomes a tall tower. If none collide, there is no pile at all.

Each photo collides only with its own depth layer, the box and the camera, so photos in different layers overlap like a real pile. New photos go to the layer where they fall furthest, which keeps the pile level. A grabbed photo pushes through every layer but ignores the photos it already overlapped, so lifting it does not shove them apart.

  • Two layers at first, one more per 10 photos, at most 6.
  • Physics steps at 1/120 s, and long frames are clamped to 1/20 s.
  • The floor is a row of slightly tilted planks, so photos rest at uneven angles.

02

Live events carry no data

Phones lose connections all the time, and a stream that must deliver every change is hard to keep correct. A slow client also must not hold up the people sending photos.

An event only says that something changed, and the app then reads the change feed from its saved cursor. A dropped event costs one late refresh, because every connect triggers a pull that catches up. Each visible change takes its sequence number while holding the box's advisory lock until commit, so a reader never skips a row that commits late.

  • Publishing never blocks: a full 8-event buffer drops the event.
  • A heartbeat every 25 s, and the app reconnects after 60 s of silence.
  • Membership is checked after subscribing, so a removal cannot slip between the two.

03

A block hides, a ban removes

Blocking is personal, since the blocked person's photos stay in the box for everyone else. A ban has to take effect everywhere and still finish correctly if storage fails halfway.

A block is one row, and the change feed filters that sender's photos for the person who blocked them. Unblocking makes the app re-read shared boxes from cursor 0, because the hidden photos sit behind its cursor. A ban sets a timestamp first, then turns the person's photos into tombstones, deletes the files and closes their streams, and repeating the call finishes whatever failed.

  • Blocked in either direction, an invite answers like a dead link.
  • Tapping Report twice stores one row and raises one alert.
  • Invite codes are 16 random bytes, expire after 7 days and are masked in logs.

Let's build
something
together.

Open to senior roles: remote, hybrid or on-site in Ho Chi Minh City. The quickest way to reach me is email or LinkedIn.