A modern, self-hosted audio+lyrics companion for the CloudNet BlockParty minigame. Plays synchronised background music (and Spotify-style LRC lyrics) in the browser while a player is ingame — server-driven, so every client in the same room stays perfectly in sync.
Minecraft (Paper + Skript) Node.js (VPS) Browser
───────────────────────────── ───────────────────── ─────────────────
BlockPartyAddon plugin ─── WS ───▶ server.js ─── WS ───▶ React player
(Java, java-websocket) (Express + ws) (Vite + Tailwind)
+ LRC lyrics
| Layer | Tech |
|---|---|
| Frontend | React 18 · TypeScript · Vite 5 · Tailwind 3 · shadcn/ui · lucide-react |
| Backend | Node.js · Express · ws |
| Plugin | Java 17 · Paper 1.20.4 · Skript 2.9.2 · java-websocket 1.5.6 · Gson |
| Deployment | pm2 (VPS) · CloudNet template plugins (MC cluster) |
- 🎵 Server-side song id resolution — the Skript just sends
"WeAreNumberOne", the Node server maps it to/music/wearenumberone.flac(lookup by normalised filename, title/artist metadata included). - 🎤 Spotify-style synced lyrics — sidecar
.lrcfiles next to each audio file. Active line glows, neighbours fade out. Scrolling is locked to song position (user can't wheel/touch-scroll the panel). - 🔄 Resume on reload — the server tracks authoritative playback time per
room. Reconnecting clients receive a
syncmessage with the current position and seek into the track automatically. - 👥 Multi-client per player — multiple browser tabs for the same MC name stay in sync.
- 🔌 Auto-reconnect on both the plugin (Paper side) and the browser (client
side) with heartbeat +
whoAmIre-identification. - 🛡️ Safety net — if the plugin forwards a raw song id instead of a URL, Node resolves it. If it forwards a full URL, Node passes it through unchanged.
├─ server.js Express + WS hub, song-id resolver, room state
├─ src/ React frontend (TS)
│ ├─ App.tsx WS client, audio element, rAF time tick
│ ├─ components/
│ │ ├─ PlayerView.tsx Main card, TrackInfo + lyrics toggle
│ │ ├─ LyricsPanel.tsx Locked-scroll LRC panel with masked fades
│ │ └─ … SkinAvatar, StatusBadge, VolumeSlider, Equalizer
│ └─ lib/lrc.ts LRC parser + `findActiveIndex` (binary search)
├─ music/ Sidecar `.lrc` files (FLACs are .gitignored)
├─ plugin/ Maven project — BlockPartyAddon (Skript add-on)
│ ├─ pom.xml
│ ├─ resources/
│ │ ├─ plugin.yml
│ │ └─ songs.yml id → file + title + artist mapping
│ └─ sources/net/skydinse/blockPartyAddon/
│ ├─ Main.java
│ ├─ other/ WS client, song registry, Bukkit events
│ └─ elements/webplayer/ Skript effects, events, expressions
└─ README.md
npm i
npm run dev # vite (5173) + server.js (3000) concurrentlyProduction:
npm i
npm run build
node server.js # serves /dist + /music + WS on :3000Drop audio files into music/ (any of .flac .mp3 .ogg .wav .m4a) and, if you
want lyrics, a sidecar .lrc with the same basename.
cd plugin
mvn -B -DskipTests package
# → plugin/target/BlockPartyAddon-1.1.0.jarDrop the jar into your MC server's plugins/ folder (or the CloudNet template).
It expects Skript 2.9.2+ and a reachable WebSocket URL (hard-coded to
ws://194.117.224.203:3000 in Main.java — edit this for your deployment).
songs.yml is auto-extracted on first enable (template below).
Lives in <server>/plugins/BlockPartyAddon/songs.yml:
url-prefix: "/music/"
songs:
WeAreNumberOne:
file: "wearenumberone.flac"
title: "We Are Number One"
artist: "LazyTown"
FindThatSomeone:
file: "findthatsomeone.flac"
title: "Find That Someone"
artist: "Televisor"
RazorSharp:
file: "razorsharp.flac"
title: "Razor Sharp"
artist: "Pegboard Nerds"The key is the song id you reference from Skript. The plugin normalises ids
to lowercase + strips non-alphanumerics, so WeAreNumberOne, we are number one
and wearenumberone all resolve to the same entry.
The stock BlockParty Skript stores the SoundCloud URL of the chosen web
song in {...song.web.link} and passes it directly to the webplayer. That
doesn't work for self-hosted audio — the browser can't stream from a random
SoundCloud URL.
Inside the function blockpartyMainDefineSongWinner(round: string):, change
line ~179 so _link.%loop-value-1% is set to the song id, not the
::link URL from settings.sk:
loop {bp.config.songs.%loop-value-1%::*}:
if loop-value-2 = {_return.%loop-value-1%::2}:
set {_display.%loop-value-1%} to {bp.config.songs.%loop-value-1%::%loop-index-2%::display}
set {_volume.%loop-value-1%} to {bp.config.songs.%loop-value-1%::%loop-index-2%::volume}
- set {_link.%loop-value-1%} to {bp.config.songs.%loop-value-1%::%loop-index-2%::link}
+ set {_link.%loop-value-1%} to {_return.%loop-value-1%::2}
stop loopOne-liner:
cd ~/cloudnet/local/templates/BlockParty/default/plugins/Skript/scripts
sed -i 's|set {_link.%loop-value-1%} to {bp.config.songs.%loop-value-1%::%loop-index-2%::link}|set {_link.%loop-value-1%} to {_return.%loop-value-1%::2}|' ./game/bp-main.sksettings.sk bp-main.sk (fixed) Plugin / Node
─────────── ────────────────── ─────────────
{bp.config.songs.web::1::link} {_return.web::2} = "WeAreNumberOne" → "/music/wearenumberone.flac"
= "https://…soundcloud…" (the yml key / song id)
(no longer used for the
webplayer path)
blockparty auth host "<label>" # optional, identify as a host
blockparty join %player% to "<room>" # put player's webplayer into the room
blockparty leave %player% # take them out
blockparty load song "<id>" in "<room>" # resolve id → url via songs.yml
blockparty load track "<url or id>" in "<room>" # legacy — takes either
blockparty play "<room>"
blockparty pause "<room>"
blockparty delete room "<room>"on webplayer connect: # fires when a browser identifies via whoAmI
on webplayer disconnect: # fires when last client of a player disconnectswebplayer name # string — last-connected player's MC nameAll messages are JSON. type is the discriminator.
type |
payload |
|---|---|
auth |
{ host: "<label>" } |
switchRoom |
{ player, room } (assigns player to room) |
load |
{ room, track, title?, artist? } |
play |
{ room } |
pause |
{ room } |
stateUpdate |
{ room, state } |
type |
payload |
|---|---|
whoAmI |
{ player } |
heartbeat |
{ player } |
type |
payload |
|---|---|
switchRoom |
{ room } (or room: null to kick out) |
sync |
{ state: { track, title, artist, time, playing } } |
load |
{ track: "/music/<file>", title, artist } |
play / pause |
{} |
leave |
{ player } |
type |
payload |
|---|---|
webplayerConnect |
{ player } |
webplayerDisconnect |
{ player } |
Server state per room:
{
track: "/music/wearenumberone.flac",
title, artist,
time: <seconds accumulated while paused>,
playing: true|false,
playStartedAt: <Date.now() when last play, or null>,
}When a client connects / switches room, the server sends a sync with
time = state.time + (playing ? (Date.now() - playStartedAt)/1000 : 0). The
client seeks to that position once metadata is loaded.
| Target | Where | How to update |
|---|---|---|
| Web (public) | root@194.117.224.203:/root/c4g7/BlockPartyPlayer/ (pm2 server) |
npm run build + rsync |
| Plugin jar | cloud@176.9.76.49:~/cloudnet/local/templates/BlockParty/default/plugins/ (port 2244) |
mvn package + scp |
Skript (bp-main.sk, settings.sk) |
cloud@176.9.76.49:~/cloudnet/local/templates/BlockParty/default/plugins/Skript/scripts/game/ |
edit + restart BP service |
# Frontend + Node server
npm run build
rsync -az dist/ root@194.117.224.203:/root/c4g7/BlockPartyPlayer/dist/
scp server.js root@194.117.224.203:/root/c4g7/BlockPartyPlayer/server.js
ssh root@194.117.224.203 'pm2 restart server'
# Plugin
cd plugin && mvn -B -DskipTests package
scp -P 2244 target/BlockPartyAddon-1.1.0.jar \
cloud@176.9.76.49:cloudnet/local/templates/BlockParty/default/plugins/
# then restart the BlockParty services in CloudNet so the new jar loads- UI + webplayer code — @c4g7
- Original BlockParty concept & Skript — Limeiyy
- Built on Skript 2.9.2 with 💚
© 2026 — All rights reserved.