blokera-net
v0.1.0
Published
TCP + gzip + framing for the vanilla ClassiCube protocol, built on protolib-js. The app only writes game logic.
Downloads
27
Maintainers
Readme
blokera-net
TCP + gzip + framing for the vanilla ClassiCube protocol, built on top
of protolib-js-style
packet definitions. The app that uses this library only writes game
logic — it never touches a Buffer, never calls gzip, never frames
a packet by hand.
Three layers, each doing exactly one job:
data/protocolo.json— the language. Declares the shape of every vanilla ClassiCube packet (0x00-0x0f), inprotolib-js's native JSON AST.protolib-js— the translator. Bytes ↔ JS object. Doesn't know what a "player" or a "block" is.blokera-net(this package) — TCP sockets, packet framing, gzip level compression/decompression, player id bookkeeping, and a smallWorldabstraction. ExposesClassiCubeServerandClassiCubeClientasEventEmitters.
Your app sits on top of all three and only sees things like
server.setBlock(x, y, z, BLOCK.STONE) and server.on("login", ...).
1. Install
npm install blokera-netprotolib-js is a regular dependency and gets installed automatically
with it.
Used as a local package, without publishing to npm:
const { ClassiCubeServer } = require("/path/to/blokera-net/src");
// or, if added as a local dependency in package.json:
const { ClassiCubeServer } = require("blokera-net");TypeScript
The library itself is plain JavaScript (CommonJS) — there's no build
step, and require("blokera-net") in Node works exactly as before.
types/index.d.ts ships alongside it and is picked up automatically
by package.json's "types" field, so TypeScript projects get full
types (including typed EventEmitter events like server.on("login",
...)) with zero configuration:
import { ClassiCubeServer, BLOCK, Player } from "blokera-net";
const server = new ClassiCubeServer({ port: 25565, cpe: true });
server.on("login", (player: Player, { username }) => {
console.log(`${username} logged in as #${player.id}`);
});
server.setBlock(0, 0, 0, BLOCK.STONE);JavaScript consumers are unaffected either way — the .d.ts file is
only ever read by tsc/editors, never by Node at runtime.
Both examples in example/ ship in JS and TS versions
(server.js/server.ts, bot.js/bot.ts) with identical behavior,
so you can compare them side by side.
2. Quick start: a server
const { ClassiCubeServer, BLOCK } = require("blokera-net");
const server = new ClassiCubeServer({
port: 25565,
width: 128,
height: 64,
length: 128,
serverName: "My classic server",
serverMotd: "hello world",
});
// Classic flat world: bedrock at the bottom, dirt in the middle, grass on top.
server.world.fillFlat([
{ blockType: BLOCK.BEDROCK, height: 1 },
{ blockType: BLOCK.DIRT, height: 30 },
{ blockType: BLOCK.GRASS, height: 1 },
]);
// IMPORTANT: call this AFTER generating terrain, or the default spawn
// gets computed against an empty (all-Air) world and lands at y=0.
server.setSpawnPoint();
server.on("login", (player, { username }) => {
console.log(`${username} logged in as player #${player.id}`);
});
server.on("message", (player, { text }) => {
server.sendMessage(`${player.name}: ${text}`);
});
server.on("setBlock", (player, event) => {
// event.cancel = true rejects the placement -- the server never
// applies it and never broadcasts it.
if (event.y === 0) event.cancel = true;
});
server.listen(() => console.log(`Listening on ${server.port}`));See example/server.js (or
example/server.ts for the TypeScript version)
for a complete, runnable version with more event handlers.
3. Quick start: a bot
const { ClassiCubeClient } = require("blokera-net");
const bot = new ClassiCubeClient({
host: "127.0.0.1",
port: 25565,
username: "botcito",
});
bot.on("spawn", (p) => {
console.log(`Spawned at (${p.x}, ${p.y}, ${p.z})`);
console.log(`World is ${bot.world.width}x${bot.world.height}x${bot.world.length}`);
bot.say("hello!");
bot.move(bot.x + 1, bot.y, bot.z);
});
bot.on("message", ({ playerId, message }) => {
console.log(`(${playerId}) ${message}`);
});
bot.connect();bot.world is a fully decoded World (gunzip + reassembly already
done for you) once 'spawn' fires — see example/bot.js
(or example/bot.ts for the TypeScript version).
4. ClassiCubeServer
new ClassiCubeServer(opts)| Option | Default | Notes |
|---|---|---|
| port | 25565 | |
| width/height/length | 64 | world dimensions |
| maxPlayers | 127 | playerId is an i8 on the wire, so this can't exceed 127 |
| serverName/serverMotd | "ClassiCube Server" / "" | |
| protocolVersion | 7 | |
| dataPath | bundled protocolo.json | pass your own if you extend the protocol |
| cpe | false | if true, negotiates CustomBlocks with clients that support it (see §11); either way, set_block with blockType > 49 is rejected for any player whose handshake didn't include CustomBlocks |
| spawnPoint | auto-computed | {x, y, z, yaw?, pitch?}; see setSpawnPoint() below |
Properties
| Property | Type | Notes |
|---|---|---|
| port/serverName/serverMotd/protocolVersion/cpe | as passed in the constructor | |
| world | World | the world instance backing this server |
| spawnPoint | {x, y, z, yaw, pitch} | where new players appear; see setSpawnPoint() |
| players | Player[] (getter) | currently logged-in players |
Events
| Event | Args | When |
|---|---|---|
| login | (player, {protocolVersion, username}) | after a client identifies |
| move | (player, {x, y, z, yaw, pitch}) | client sent a position update |
| setBlock | (player, event) | before the change is applied — set event.cancel = true to reject it. Not emitted at all if blockType > 49 and this player didn't negotiate CustomBlocks (see §11) — the server silently corrects the client's local placement instead of bothering the app |
| message | (player, {text}) | chat message from a client |
| disconnect | (player) | socket closed after a successful login |
| error | (err, player \| null) | parse errors, unhandled packets, socket errors |
Methods
| Method | Does |
|---|---|
| listen(callback?) | starts the TCP server |
| close(callback?) | stops it |
| setBlock(x, y, z, blockType) | updates world and broadcasts to everyone |
| getBlock(x, y, z) | reads from world |
| sendMessage(text, player?) | broadcast, or DM if player is given |
| kick(player, reason?) | sends disconnect_player and closes the socket |
| setSpawnPoint(point?) | recompute (or set explicitly) where new players spawn |
Why setSpawnPoint() is separate from the constructor: the
default spawn is the center of the map, at the first Air cell counted
from the bottom — but at construction time the world is still empty
(all Air), so that calculation would give y = 0. Generate your
terrain first (world.fillFlat(...) or your own generation), then
call server.setSpawnPoint() to recompute it against the real
terrain.
5. ClassiCubeClient
new ClassiCubeClient({ host, port = 25565, username, verificationKey = "", protocolVersion = 7, cpe = false, dataPath })Properties
| Property | Type | Notes |
|---|---|---|
| host / port | string / number | as passed in the constructor |
| username / verificationKey | string | as passed in the constructor |
| protocolVersion | number | as passed in the constructor |
| cpe | boolean | as passed in the constructor; see §11 |
| ext | ExtNegotiation | tracks what the server announced during the CPE handshake |
| world | World \| null | null until 'spawn' fires, then the fully decoded level |
| playerId | number \| null | always -1 once spawned (how ClassiCube represents "yourself" on the wire) |
| x/y/z/yaw/pitch | number | your own last known position; updated by move() and by the 'spawn' packet |
Events
| Event | Args |
|---|---|
| connect | () — TCP socket open |
| login | ({serverName, serverMotd, userType}) |
| levelStart | () — level starting to arrive |
| levelProgress | (percentComplete) — per chunk |
| spawn | ({playerId, x, y, z, yaw, pitch}) — level finished loading, bot.world is ready |
| playerSpawn | ({playerId, playerName, x, y, z, yaw, pitch}) — another player appears |
| playerDespawn | (playerId) |
| move | ({playerId, x, y, z, yaw, pitch}) |
| message | ({playerId, message}) |
| setBlock | ({x, y, z, blockType}) — already applied to bot.world |
| cpeReady | () — server's ExtEntry list fully received; hasCustomBlocks() now reflects the final state (only fires if cpe: true) |
| disconnect | ({reason}) — server kicked you |
| close | () |
| error | (err) |
Methods
| Method | Does |
|---|---|
| connect() | opens the TCP connection and sends identification once open |
| disconnect() | closes the socket |
| move(x, y, z, yaw?, pitch?) | sends your position |
| say(message) | sends chat |
| placeBlock(x, y, z, blockType, mode = true) | mode: false breaks instead of placing |
| breakBlock(x, y, z) | shorthand for placeBlock(..., 0, false) |
| hasCustomBlocks() | true if the server negotiated CustomBlocks with this bot — check before using block ids above 49 |
6. World
Owns the block state (Uint8Array, one byte per cell) and the wire
format conversions (raw blob ⇄ gzip ⇄ 1024-byte chunks). Cell index:
i = (y * length + z) * width + x — this is how ClassiCube itself
orders a level on the wire, not an arbitrary choice.
const world = new World(width, height, length, { fill: 0 });Properties
| Property | Type | Notes |
|---|---|---|
| width/height/length | number | as passed in the constructor |
| blocks | Uint8Array | raw block state, width*height*length bytes, one per cell |
Methods
| Method | Returns | Does |
|---|---|---|
| index(x, y, z) | number | cell index into blocks for the given coordinate |
| inBounds(x, y, z) | boolean | whether the coordinate is inside the world |
| getBlock(x, y, z) | number \| null | null if out of bounds |
| setBlock(x, y, z, blockType) | boolean | false if out of bounds (no-op) |
| fill(blockType) | void | whole world, one block type |
| fillFlat(layers) | number | layered flat terrain, bottom to top; returns the resulting ground height (first Air layer) |
| toRawBlob() | Buffer | wire format: u32BE block count + blocks |
| toGzippedBlob() | Buffer | toRawBlob(), gzip-compressed |
| loadFromRawBlob(blob) | void | replaces blocks from an already-decompressed raw blob; throws RangeError if the block count doesn't match this world's dimensions |
world.getBlock(x, y, z); // number | null (null if out of bounds)
world.setBlock(x, y, z, type); // boolean, false if out of bounds
world.fill(type); // whole world, one block type
world.fillFlat([ // layered flat terrain, bottom to top
{ blockType: BLOCK.BEDROCK, height: 1 },
{ blockType: BLOCK.DIRT, height: 30 },
{ blockType: BLOCK.GRASS, height: 1 },
]); // returns the resulting ground height (first Air layer)World.chunksOf1024(buffer) — static
(buffer: Buffer) -> {chunkLength: number, chunkData: Buffer, percentComplete: number}[]
Splits a buffer into level_data_chunk-ready pieces (each chunkData
always exactly 1024 bytes, 0x00-padded if the last piece is
shorter). This is what ClassiCubeServer calls internally when
sending the level to a newly-logged-in player.
LevelReceiver
The receiving half of the same dance: collects level_data_chunk
packets as they arrive and, once level_finalize shows up, gunzips
everything and builds a World sized from that packet. This is what
ClassiCubeClient uses internally to fill bot.world.
const receiver = new LevelReceiver();
// on level_initialize:
receiver.start();
// on each level_data_chunk:
receiver.addChunk(params); // params = {chunkLength, chunkData, percentComplete}
// on level_finalize:
const world = receiver.finish(params); // params = {xSize, ySize, zSize} -> World| Method | Returns | Does |
|---|---|---|
| start() | void | resets internal buffers, call on level_initialize |
| addChunk({chunkLength, chunkData}) | void | accumulates one level_data_chunk's payload |
| finish({xSize, ySize, zSize}) | World | gunzips everything collected and builds the World |
You only need World.chunksOf1024/LevelReceiver directly if you're
building something lower-level than ClassiCubeServer/ClassiCubeClient
— both already wire this up for you.
7. BLOCK
66 block ids, frozen, name → id. Ids 0-49 are vanilla (always safe to
use). Ids 50-65 are CPE CustomBlocks level 1 — only meaningful if
the connecting client negotiated that extension, which this package
doesn't do yet (see §10).
ClassiCubeServer rejects any set_block with blockType > 49
unless constructed with { cpe: true } — see §4.
const { BLOCK, blockName, MAX_VANILLA_BLOCK_ID } = require("blokera-net");
BLOCK.GRASS; // 2
BLOCK.BEDROCK; // 7
BLOCK.ICE; // 60 (CPE, id > MAX_VANILLA_BLOCK_ID)
MAX_VANILLA_BLOCK_ID; // 49
blockName(2); // "GRASS"
blockName(999); // nullblockName(id) does a reverse lookup (number -> string | null) —
useful for logging (console.log(blockName(blockType)) instead of a
bare number).
8. Player
Plain state object for a connected client — no methods, just fields
that ClassiCubeServer keeps up to date. You get these from the
player argument in every server event (login, move, setBlock,
message, disconnect), and from server.players.
| Property | Type | Notes |
|---|---|---|
| id | number | the wire playerId (i8, 0..maxPlayers-1), assigned by PlayerIdPool |
| socket | net.Socket | the raw TCP socket — you generally shouldn't write to this directly, use server methods instead |
| name | string \| null | null until identification arrives |
| loggedIn | boolean | true once identification has been processed |
| x/y/z/yaw/pitch | number | last known position, updated on identification (set to server.spawnPoint) and on every position_orientation |
| ext | ExtNegotiation | this player's CPE handshake state; see §11 |
PlayerIdPool
Hands out and recycles ids in [0, maxPlayers) so a long-running
server doesn't run past the i8 range of the wire format instead of
incrementing forever. ClassiCubeServer owns one internally
(opts.maxPlayers); you generally don't need to touch this class
directly unless you're building your own connection-handling layer.
new PlayerIdPool(maxPlayers = 127) // throws RangeError if maxPlayers > 127| Method | Returns | Does |
|---|---|---|
| acquire() | number \| null | a free id, or null if the pool is exhausted (server full) |
| release(id) | void | returns an id to the free pool |
9. Errors
None of these are localized — messages are always in English so logs are greppable regardless of what language your game-facing strings (chat messages, MOTDs) are in.
| Thrown by | Error | When |
|---|---|---|
| new World(...) | TypeError | width/height/length aren't integers |
| world.loadFromRawBlob(...) | RangeError | decoded block count doesn't match this world's dimensions |
| new ClassiCubeClient(...) | TypeError | missing host or username |
| new PlayerIdPool(...) | RangeError | maxPlayers > 127 |
ClassiCubeServer/ClassiCubeClient never throw synchronously from
inside a socket's 'data' handler — parse errors, unhandled packet
names, and socket errors are all funneled into the 'error' event
instead ((err, player | null) for the server, (err) for the
client), so a bad packet from one client can't crash the whole
process. BufferUnderrun (from protolib-js) is handled internally
and is never surfaced as an 'error' — it just means "wait for more
bytes," which both classes already do for you.
10. cpe.js: CPE handshake building blocks
Shared logic used internally by both ClassiCubeServer and
ClassiCubeClient so their negotiation code doesn't drift apart. You
generally don't need to touch this directly — server.js and
client.js already wire it up — but it's exported in case you're
building something lower-level.
const { CPE_PROTOCOL_VERSION, CUSTOM_BLOCKS_SUPPORT_LEVEL, SUPPORTED_EXTENSIONS, ExtNegotiation } = require("blokera-net");| Export | Value / Type | Notes |
|---|---|---|
| CPE_PROTOCOL_VERSION | 0x42 | the value sent in Identification's unused (padding) field to announce CPE support — NOT protocolVersion, which is always sent as the normal version |
| CUSTOM_BLOCKS_SUPPORT_LEVEL | 1 | the only level this package understands (block ids 50-65) |
| SUPPORTED_EXTENSIONS | [{name: "CustomBlocks", version: 1}] | frozen; what this package announces in its own ExtEntry list |
| ExtNegotiation | class | tracks one connection's handshake state (see below) |
ExtNegotiation
One instance per connection — ClassiCubeServer gives one to each
Player (player.ext), ClassiCubeClient keeps its own (client.ext).
| Property/Method | Type | Notes |
|---|---|---|
| remoteSupportsCpe | boolean | whether the other side sent ExtInfo at all |
| remoteExtensions | Map<string, number> | extName -> version, filled in as ExtEntry packets arrive |
| remoteListComplete | boolean | true once every announced ExtEntry has arrived |
| receiveExtInfo({extensionCount}) | void | call when an ext_info packet arrives |
| receiveExtEntry({extName, version}) | void | call when an ext_entry packet arrives |
| hasCustomBlocks() | boolean | shorthand for remoteExtensions.has("CustomBlocks") |
11. CPE handshake
How it actually works, end to end:
- The connecting side sends
Identificationwith itsunused(padding) field set to0x42(CPE_PROTOCOL_VERSION) —protocolVersionitself stays at the normal0x07— to announce CPE support. - If the other side also has
cpe: true, it replies with its normalIdentification, thenExtInfo(how many extensions it has) followed by that manyExtEntrypackets. - The first side does the same back.
- Only once both sides finished exchanging their lists can extension-specific packets be used — for
CustomBlocks, that'sCustomBlockSupportLevel. - If either side never sends
ExtInfo, there's no CPE for that connection — everything falls back to vanilla, no error, no extra step.
On ClassiCubeServer: set { cpe: true }. For each connected
player, the server waits for their ExtEntry list to finish (tracked
in player.ext) before sending the level — that way, if the player
negotiated CustomBlocks, CustomBlockSupportLevel goes out before
any block data, so the client already knows blocks 50-65 might show
up. A 3-second safety timeout sends the level anyway if a client
claims CPE support but never finishes its own ExtInfo/ExtEntry
(broken client, or one that silently ignored the packets) — it's
treated as if it had no CustomBlocks support instead of hanging the
player forever. setBlock()/incoming set_block both check
player.ext.hasCustomBlocks() (not just this.cpe) before allowing
block ids above 49 — a cpe: true server still rejects them for a
specific player whose own handshake didn't include CustomBlocks.
On ClassiCubeClient: set { cpe: true }. The bot's own
handshake state lives in client.ext; client.hasCustomBlocks() is
the thing to check before calling placeBlock/breakBlock with an
id above 49. The 'cpeReady' event fires once the server's
ExtEntry list is fully received — that's the signal that
hasCustomBlocks() reflects the final negotiated state, not a
still-in-progress one.
const bot = new ClassiCubeClient({ host, username, cpe: true });
bot.on("cpeReady", () => {
if (bot.hasCustomBlocks()) bot.placeBlock(x, y, z, BLOCK.ICE);
});12. Scope
This package currently implements the 14 vanilla ClassiCube packets
(0x00-0x0f, toServer + toClient), plus a real CPE handshake
limited to one extension: ExtInfo/ExtEntry/CustomBlockSupportLevel
negotiating CustomBlocks level 1 (block ids 50-65 in BLOCK). No
other CPE extensions yet — no ClickDistance, HeldBlock,
ExtEntityPositions, EnvColors, etc.
Likely direction for future versions: more CPE extensions, added
one at a time as separate layers on top of what's here (each with its
own packet definitions in protocolo.json and its own negotiated
SUPPORTED_EXTENSIONS entry), not a rewrite of what already works.
Not a promise, just where this is headed.
