npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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), in protolib-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 small World abstraction. Exposes ClassiCubeServer and ClassiCubeClient as EventEmitters.

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-net

protolib-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);           // null

blockName(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:

  1. The connecting side sends Identification with its unused (padding) field set to 0x42 (CPE_PROTOCOL_VERSION) — protocolVersion itself stays at the normal 0x07 — to announce CPE support.
  2. If the other side also has cpe: true, it replies with its normal Identification, then ExtInfo (how many extensions it has) followed by that many ExtEntry packets.
  3. The first side does the same back.
  4. Only once both sides finished exchanging their lists can extension-specific packets be used — for CustomBlocks, that's CustomBlockSupportLevel.
  5. 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.