A single-page chat built on the Firebase Realtime Database.
Two files do everything: index.html holds the entire application (markup, styles and script), and this page explains it. Messages live in one Firebase Realtime Database, so every person with the room code sees the same conversation in real time, including everything said before they arrived.
Any member may click the name at the top of the chat and type a new one. The room code never changes, so existing invitations keep working.
This copy of index.html already contains a working firebaseConfig, so steps 1 to 3 below are only needed if you want to point the site at your own Firebase project. Steps 4 and 5, anonymous sign-in and the database rules, must both be done in the Firebase console before any hall can be created or joined.
You need a free Firebase project with a Realtime Database (not Cloud Firestore).
</>), give it a nickname, and register it. Hosting is not required.firebaseConfig object that is shown.Open index.html, scroll to the script near the bottom, and replace the placeholder block:
var firebaseConfig = {
apiKey: "AIza...",
authDomain: "your-project.firebaseapp.com",
databaseURL: "https://your-project-default-rtdb.firebaseio.com",
projectId: "your-project",
storageBucket: "your-project.appspot.com",
messagingSenderId: "000000000000",
appId: "1:000000000000:web:abcdef"
};
databaseURL is the important one. If the config Firebase gives you does not include it, copy the URL shown at the top of the Realtime Database page. Regional databases look like https://your-project-default-rtdb.europe-west1.firebasedatabase.app.
If you open the site before editing the file, a setup screen appears where you can paste the config for testing. That copy is stored only in your own browser and is not shared with anyone else, so the file itself must be edited for a real deployment.
The rules below rely on a verified user id, so the site signs every visitor in anonymously. In the console open Build then Authentication, click Get started, choose Anonymous under Sign-in method, and enable it. Nobody is asked for an email or password; each browser is simply given a uid that the database can trust. If this is switched off, the gate reports that anonymous sign-in is unavailable.
A database left in locked mode refuses everything, and test mode lets the whole internet do as it pleases. Open Build then Realtime Database then Rules, paste the ruleset below, and press Publish. It requires a signed-in user, lets only the author edit or delete a message, reserves the password and the room itself for the founder, and allows expired messages to be swept up by anyone. The presence branch lets each visitor register only their own name; it is what fills the @ list and rings the enter and leave chimes.
{
"rules": {
"rooms": {
".read": false,
".write": false,
"$code": {
".read": "auth != null",
".write": "auth != null && (!data.exists() || (data.child('owner').val() === auth.uid && !newData.exists()))",
"name": { ".write": "auth != null", ".validate": "newData.isString() && newData.val().length <= 40" },
"owner": { ".validate": "newData.val() === auth.uid" },
"createdAt": { ".validate": "newData.val() <= now" },
"retention": { ".write": "auth != null", ".validate": "newData.isNumber()" },
"hasPass": { ".write": "auth != null && data.parent().child('owner').val() === auth.uid" },
"pass": { ".write": "auth != null && data.parent().child('owner').val() === auth.uid" },
"messages": {
"$id": {
".write": "auth != null && ( (!data.exists() && newData.child('uid').val() === auth.uid) || data.child('uid').val() === auth.uid || root.child('rooms').child($code).child('owner').val() === auth.uid || (!newData.exists() && root.child('rooms').child($code).child('retention').val() > 0 && data.child('ts').val() < (now - root.child('rooms').child($code).child('retention').val())) )",
".validate": "newData.hasChildren(['uid','name','text','ts'])",
"uid": { ".validate": "newData.val() === data.parent().child('uid').val() || newData.val() === auth.uid" },
"text": { ".validate": "newData.isString() && newData.val().length <= 2000" },
"name": { ".validate": "newData.isString() && newData.val().length <= 24" },
"ts": { ".validate": "newData.isNumber()" }
}
},
"presence": {
"$uid": {
".write": "auth != null && $uid === auth.uid",
"name": { ".validate": "newData.isString() && newData.val().length <= 24" },
"at": { ".validate": "newData.val() <= now" }
}
}
}
}
}
}
What this buys you: a stranger who copies your config out of the page still cannot read a room without signing in, cannot forge a message under someone else's uid, cannot edit or delete anyone else's words, and cannot take a password off a hall or destroy a hall that is not theirs. What it does not buy you: privacy from anyone who has both the code and an anonymous account, since the join screen compares the password hash in the browser and therefore has to read pass. Treat the room password as a courtesy lock; move the check to a Cloud Function if it must truly be enforced.
Because the site now uses Authentication, add your GitHub Pages domain (yourname.github.io) under Authentication then Settings then Authorised domains. localhost is allowed by default.
index.html, instructions.html, manifest.webmanifest, icon.png and README.md at its root.main and the / (root) folder, and save.https://yourname.github.io/your-repo/. Share that link plus a room code.The page must be served over https for full functionality; GitHub Pages does that automatically. Opening the file directly from disk works for a quick test but falls back to a weaker password hash.
rooms/
MORIA1/
name: "The Council of Elrond"
owner: "u1a2b3c" (anonymous auth uid of the founder)
createdAt: 1725148800000
retention: 0 (0, 600000 or 86400000 milliseconds)
hasPass: true
pass: "<sha-256 hash>"
messages/
-Nx1abc.../
uid: "u1a2b3c" (anonymous auth uid of the sender)
name: "Peregrin Took"
text: "Second breakfast?"
ts: 1725148900000 (server timestamp, used for ordering)
edited: false
deleted: false
mentions: ["Frodo"] (present only when someone was called out)
replyTo: { name: "Frodo", text: "..." }
presence/
u1a2b3c/
name: "Peregrin Took" (removed automatically when the tab closes)
at: 1725148900000