Devlog 7: No Login Required - Device Fingerprinting for Anonymous Players
One of my core principles for Tuggy: you should be able to land on a matchup and start voting within 2 seconds. No signup forms, no email confirmation, no friction.
But I still need to track who voted for what, both for leaderboards and to prevent simple vote manipulation. So I needed a way to identify anonymous users without requiring authentication.

Device Fingerprinting
The solution is generating a unique ID for each device/browser combo:
function getDeviceId(): string {
let deviceId = localStorage.getItem('tuggy_device_id');
if (!deviceId) {
deviceId = generateFingerprint();
localStorage.setItem('tuggy_device_id', deviceId);
}
return deviceId;
}
function generateFingerprint(): string {
const components = [
navigator.userAgent,
navigator.language,
screen.width,
screen.height,
new Date().getTimezoneOffset(),
];
return hashComponents(components);
}
This creates a stable ID that persists across sessions. It's not perfect (clearing localStorage resets it), but it's good enough for casual use. And importantly, it's all client-side. No tracking cookies, no third-party services, just localStorage.
Dual Attribution System
In the database, votes can be attributed to either a user ID or a device ID:
interface Vote {
matchup_id: string;
user_id?: string; // If logged in
device_id?: string; // If anonymous
side: 'a' | 'b';
amount: number;
}
The schema enforces that every vote has one or the other:
CREATE TABLE votes (
id UUID PRIMARY KEY,
user_id UUID REFERENCES auth.users,
device_id TEXT,
CHECK (user_id IS NOT NULL OR device_id IS NOT NULL)
);
So anonymous users and authenticated users work exactly the same way from the voting system's perspective.
Progressive Authentication
Here's the cool part: when an anonymous user eventually decides to sign up, I migrate all their device_id votes to their new user_id:
async function migrateAnonymousData(userId: string, deviceId: string) {
await supabase
.from('votes')
.update({ user_id: userId, device_id: null })
.eq('device_id', deviceId);
await supabase
.from('leaderboard')
.update({ user_id: userId })
.eq('device_id', deviceId);
}
So they don't lose any progress. Their votes, tugs, everything carries over seamlessly. This makes the signup decision feel safe because you're not starting over.
When To Prompt Signup
I don't shove signup in people's faces immediately. Instead, I prompt at strategic moments:
- After earning an achievement
- After reaching top 10 on a leaderboard
- After voting on 3+ different matchups
Basically, once they're already engaged and seeing value, then I suggest they create an account to save progress across devices.
Privacy Considerations
I'm trying to be thoughtful about privacy here:
- Device IDs are hashed and stored only in localStorage
- No third-party tracking scripts
- No personal data collected for anonymous users
- Easy to "reset" by clearing localStorage
- Full data export available on request
It's not as robust as real authentication, but it strikes a good balance between friction and functionality.
Results
Since launching with this system, about 85% of users vote anonymously and never sign up. That's fine! They still have a great experience. The 15% who do sign up tend to be power users who want leaderboards and cross-device sync.
Most importantly, nobody is bouncing because they hit a signup wall. The conversion funnel is: arrive → vote → maybe sign up later. Not: arrive → signup form → bounce.
Next week I'm adding analytics, which is going to be interesting to balance with the privacy-first approach I'm taking here.