The idea behind Hovr is simple: you walk down the street, open the app, and discover messages left by other people at that exact spot. A good tip, an alert, a memory, a lost pet. Words suspended in space, visible only when you're nearby.
No algorithmic feed, no followers, no likes. Just messages anchored in the real world, that only exist if you're there to read them.
The concept: 500 meters, no more
The heart of Hovr is a 500-meter visibility radius. On the iOS app, you only see messages within a 500m circle around you. Beyond that, between 500m and 1,500m, mystery markers appear - question marks that tell you "there's something over there, walk to discover it".
| Zone | Distance | Display | Purpose |
|---|---|---|---|
| Visible | 0 - 500m | Colored markers + readable content | Main interaction zone |
| Mystery | 500m - 1,500m | "?" markers with no content | Encourage exploration |
| Invisible | > 1,500m | Nothing | Limit load and relevance |
This 500m radius isn't arbitrary. It's a balance between relevance (nearby messages are actionable), performance (less data to load) and engagement (physical exploration becomes the discovery mechanism).
Architecture overview
No custom backend, no Node server, no REST API to maintain. Supabase provides everything: database, authentication, row-level security and RPC functions. The only external service is Mapbox for the maps.
Categories: 9 message types
Every Hovr is tagged with a category. No free-form tags - the choice is constrained to keep consistency and enable filtering:
Each category has its own color and icon. On the map, a single glance is enough to tell a good tip (green) from an alert (red) or a memory (pink).
PostGIS: the spatial magic
The technical core of Hovr is PostGIS, the spatial extension for PostgreSQL. It's what answers the question "which messages are within a 500m radius of me?" in a few milliseconds.
The hovrs table schema
CREATE TABLE hovrs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
category_id INTEGER REFERENCES categories(id),
content TEXT NOT NULL,
location GEOGRAPHY(POINT, 4326) NOT NULL, -- le type magique
is_hidden BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT content_length CHECK (
char_length(content) >= 1 AND char_length(content)
The GEOGRAPHY(POINT, 4326) type is the key. Unlike a simple FLOAT latitude, FLOAT longitude, it encodes coordinates in the WGS84 geodetic system (the same one GPS uses). Distance calculations are then in real meters, not degrees - which avoids projection errors.
The RPC function: get_hovrs_in_radius
CREATE FUNCTION get_hovrs_in_radius(
user_lat DOUBLE PRECISION,
user_lng DOUBLE PRECISION,
radius_meters INTEGER DEFAULT 500
)
RETURNS TABLE (...) AS $$
BEGIN
RETURN QUERY
SELECT h.*, p.username, c.name, c.color,
ST_Distance(
h.location::geography,
ST_SetSRID(ST_MakePoint(user_lng, user_lat), 4326)::geography
) as distance_meters
FROM hovrs h
JOIN profiles p ON h.user_id = p.id
JOIN categories c ON h.category_id = c.id
WHERE h.is_hidden = FALSE
AND ST_DWithin(
h.location,
ST_SetSRID(ST_MakePoint(user_lng, user_lat), 4326)::geography,
radius_meters
)
ORDER BY distance_meters ASC;
END;
$$ LANGUAGE plpgsql;
ST_DWithin is the spatial predicate that does the work. It uses the GIST index to find points within the radius without scanning the whole table. For 10,000 Hovrs, the query takes ~150ms - even on Supabase's free plan.
Mystery markers: the second query
A similar function get_mystery_hovrs returns Hovrs between 500m and 1,500m, but without the content - just the coordinates and distance. The user sees that something is there, but not what. They have to walk.
-- Anneau 500m-1500m : position seulement, pas de contenu
WHERE ST_DWithin(h.location, ..., 1500) -- dans le cercle exterieur
AND NOT ST_DWithin(h.location, ..., 500) -- mais pas dans le cercle interieur
The iOS app: SwiftUI + MVVM
The iOS app is built in SwiftUI with a strict MVVM architecture. The LocationService tracks GPS position, the MapViewModel manages visible Hovrs, and the views only handle rendering.
Writing a Hovr in PostGIS
When the user creates a message, the coordinates are sent in the WKT (Well-Known Text) format that PostGIS understands natively:
// Swift - CreateHovrRequest
struct CreateHovrRequest: Encodable {
let userId: UUID
let categoryId: Int
let content: String
let location: String // format PostGIS
init(userId: UUID, categoryId: Int, content: String,
latitude: Double, longitude: Double) {
self.userId = userId
self.categoryId = categoryId
self.content = content
self.location = "POINT(\(longitude) \(latitude))"
}
}
Refresh at 50 meters
The app doesn't poll continuously. The CLLocationManager is configured with a distanceFilter of 50 meters - the delegate is only called once the user has moved 50m. On each significant movement, Hovrs are reloaded with the new center:
// LocationService.swift
locationManager.distanceFilter = 50 // metres
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
self.userLocation = location.coordinate
self.onSignificantLocationChange?() // reload Hovrs
}
No timer, no polling. The app is reactive to position, not to time.
The web: Mapbox GL JS in vanilla JS
The web interface is a map viewer in vanilla JS with Mapbox GL JS. It displays all Hovrs without any distance restriction - it's a visualization and monitoring tool, not the main experience.
Client-side clustering
When several Hovrs are at the same spot (less than 5 meters apart), the web app groups them into clusters. The distance calculation uses the Haversine formula - the great-circle distance on a sphere:
// Haversine : distance en metres entre deux coordonnees GPS
getDistance(lat1, lon1, lat2, lon2) {
const R = 6371000; // rayon de la Terre en metres
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) *
Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
The comic-book bubble display
When you click on a marker, the Hovr's detail appears in a comic-book speech bubble - a CSS element with a triangle as a pseudo-element, pointing to the marker:
#hovr-detail::after {
content: '';
position: absolute;
bottom: -15px;
left: 50%;
transform: translateX(-50%);
border-left: 12px solid transparent;
border-right: 12px solid transparent;
border-top: 15px solid white;
}
The appearance animation uses a cubic-bezier with a bounce - a scale that overshoots 1 before settling, like a bubble that "pops":
@keyframes bubblePop {
0% { opacity: 0; transform: translateX(-50%) scale(0.5) translateY(20px); }
100% { opacity: 1; transform: translateX(-50%) scale(1) translateY(0); }
}
Security: Row Level Security
Supabase exposes the database directly to clients. Security doesn't rely on a backend that filters - it lives in PostgreSQL's RLS policies :
-- Tout le monde peut lire les Hovrs non-caches
CREATE POLICY "Hovrs visible by all"
ON hovrs FOR SELECT
USING (is_hidden = FALSE);
-- On ne peut creer que ses propres Hovrs
CREATE POLICY "Create own hovrs"
ON hovrs FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Seuls les admins peuvent modifier
CREATE POLICY "Admins update any hovr"
ON hovrs FOR UPDATE
USING (auth.uid() IN (SELECT user_id FROM admin_users));
This is a paradigm shift compared to a classic backend. Instead of validating in the controller, you validate in the database. The client can try anything - PostgreSQL refuses if the policy doesn't match.
Moderation: the admin dashboard
A web dashboard /check/ allows moderating content:
- Flags - users can report a Hovr. The admin sees pending reports and decides: hide the message or leave it
- Ban - banning a user automatically hides all their Hovrs and comments via a SQL function
SECURITY DEFINER - Stats - a SQL view
admin_statsaggregates the counts in a single query
-- Bannir un utilisateur = cacher tout son contenu
CREATE FUNCTION ban_user(target_user_id UUID, reason TEXT)
RETURNS VOID AS $$
BEGIN
UPDATE profiles SET is_banned = TRUE, banned_reason = reason
WHERE id = target_user_id;
UPDATE hovrs SET is_hidden = TRUE WHERE user_id = target_user_id;
UPDATE comments SET is_hidden = TRUE WHERE user_id = target_user_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Project structure
Tech stack
- iOS app - Swift 5.9, SwiftUI, iOS 17+, MVVM
- Web - vanilla HTML/CSS/JS, Mapbox GL JS
- Backend - Supabase (PostgreSQL + PostGIS), RPC functions
- Auth - Supabase Auth (email + Sign in with Apple)
- Maps - Mapbox (iOS SDK + GL JS)
- Security - Row Level Security (RLS), PostgreSQL policies
- Moderation - web admin dashboard, flags, bans
- Cost - Supabase free plan + Mapbox (50k free loads/month)
What I learned
- PostGIS changes the game. Calculating distances on a sphere, filtering by radius, indexing spatially - all in native SQL. No need for an external library or client-side computation.
- Supabase is a genuine shortcut. Auth, database, RLS, RPC functions - it's all there. For an MVP, it's a massive time saver compared to a custom backend.
- The visibility radius is a game mechanic. Mystery markers turn a simple map into an exploration experience. It's game design applied to a social app.
- RLS replaces the backend. Instead of validating in a controller, you validate in the database. It's safer (impossible to bypass) and simpler (a single source of truth).
- Clustering is a classic mapping problem. The Haversine formula sounds intimidating, but it's just trigonometry on a sphere. And a 5-meter threshold is enough to prevent marker stacking.