A Minecraft server core written from scratch in Java 21, built around a region-threaded tick engine: the world is partitioned into independent regions that tick in parallel on separate threads, each at its own 20 TPS, with no global tick loop and no locks on world data.
Same core idea as Folia, implemented from zero rather than as patches over Mojang's code.
This is a working server core, not a drop-in replacement for server.jar. Be clear on the split
before you judge it:
Verified working (measured, not assumed — see Benchmarks):
- Region-threaded tick engine: regions form, merge, split and retire correctly
- Global safepoints for every structural change, with no locks on chunk or entity state
- Full connection lifecycle: handshake → status → login → configuration → play
- Packet framing, zlib compression, keep-alives, timeouts
- Async multi-threaded world generation (noise and superflat)
- Chunk streaming with a ticket-based load/unload lifecycle
- Chat, movement, per-region metrics, console commands
- Block breaking and placing, with change broadcasts and sequence acknowledgement
- Anvil persistence: edits survive unload and restart, and the world opens in external tools
- Full creative inventory: any of the 952 placeable items can be picked and placed
- Entity tracking, so players see each other move
- Player data, so you return where you left off
- Block entities and working containers, stored in Anvil and preserved even when unmodelled
- Item entities: drops fall, merge, and are picked up with no duplication or loss
- Propagating sky and block light, with no cross-region coordination needed
- Block physics: sand and gravel fall as entities, water and lava flow and drain
- Basic redstone: dust, levers, torches, repeaters and lamps
- Crafting: all 932 vanilla crafting recipes, at a crafting table
- Signs you can write on and read back
- Two-block structures: doors, beds and tall plants place and break as one
- Doors, trapdoors and fence gates open on right-click (iron ones need redstone, as in vanilla)
- Water and lava buckets pour and scoop
- Random ticks: grass spreads onto bare dirt and dies back under cover
- Item entities persist across chunk unload, in vanilla's own
entities/region files
Not implemented — deliberately, and it would be dishonest to imply otherwise:
- Block behaviour. Gravity, fluids, basic redstone and grass spread work. Crops, leaf decay, fire and everything else do not.
- Mobs and combat. Players, dropped items and falling blocks move; nothing else does.
- Survival mechanics. Containers, the inventory, item entities and crafting are real, but there is no stack accounting on placement — creative items are infinite and placing never consumes one. Breaking a block does not drop it, which matches vanilla creative.
- Block entity contents beyond containers. Chests and their kin are sent to the client; signs, banners and spawners have no modelled data of their own.
- Furnaces, brewing stands, enchanting tables. Containers with processing logic, not just slots.
- Reading arbitrary vanilla worlds without a
blocks.jsonto hand. Worlds are Anvil, but the built-in block table only covers blocks this server itself uses. See Persistence. - Online mode. No encryption, no Mojang session check. Startup fails if you enable it rather than silently running something insecure. Do not expose this to the internet.
- Plugin API.
Verified against a real client (vanilla 1.21.4 via PrismLauncher) for handshake, status, login
and configuration; every packet ID in Protocol and
every block state ID in Blocks now comes from
Mojang's own generated reports rather than from memory.
Two real bugs were found this way, both worth knowing about because neither is guessable:
- Dangling registry reference. The wolf variants named
minecraft:taigawhile the biome registry contained onlyminecraft:plains. The client resolves cross-registry references when it handles Finish Configuration and aborts with "Unbound values in registry". Any biome named anywhere inRegistriesmust also be sent by it — hence the singleONLY_BIOMEconstant. - Colliding packet ID.
set_default_spawn_positionwas0x5A; the real value is0x5B, and0x5Aisset_cursor_item. The client happily decoded our 12-byte spawn packet as an item stack and died with "found 11 bytes extra" — naming a packet we never sent. A wrong ID shows up as a failure in something unrelated, which is exactly why guessing them is a bad idea.
A vanilla 1.21.4 client now joins, renders the world and plays: it sends player_loaded, walks and
looks (the movement packets are handled, not merely tolerated), streams chunks correctly as it
moves, flies in creative, and disconnects cleanly with every chunk ticket released. Ticks held
20 TPS throughout.
Breaking and placing blocks work. What the client still sends and this server ignores: swing,
player_abilities, and the inventory packets.
Requires JDK 21+. Nothing else — the Gradle wrapper is committed.
./gradlew fatJarjava -jar build/libs/quasar-0.1.0-all.jarFirst run writes quasar.properties with every setting spelled out. Console commands: help,
status, regions, players, world, mem, say <msg>, stop.
A region owns a set of chunks and every entity standing in them. While it ticks, its worker thread is the only thread allowed to read or write that state. There is not a single lock on block data or entity fields anywhere in this project — correctness comes entirely from that invariant.
Two things preserve it:
- Link radius. Chunks within 2 of each other are always in the same region, and regions are the connected components of that relation. Nothing a tick does reaches further than that, so a region can never need state another region owns.
- Safepoints. Region membership only changes when no region is ticking.
Anything that must touch a different region posts to that region's mailbox, drained by its owner at the top of its next tick.
The invariant is enforced, not merely documented — see Ownership. Every block read and write, every entity move, and every structural change is checked, and a violation is an immediate stack trace naming the thread, the region and the coordinate rather than a silent data race.
Each region carries its own deadline and is dispatched to a work-stealing pool when it comes due. A single dispatcher thread makes scheduling decisions; workers only execute. A region full of expensive work slows down nobody but itself.
When structural work is queued, the dispatcher stops starting ticks and waits for in-flight ones
to drain. That bounds the pause by the slowest single tick rather than by anything unbounded, and it
gives the rest of the server a stop-the-world primitive (RegionScheduler.runAtSafepoint) for
chunk unloading, shutdown, and anything else needing a consistent view. Safepoints are rate-limited,
because chunk loads and unloads are near-continuous while players move and one safepoint per change
would serialise the whole server.
Ticket taken → generated on the world-gen pool → published and adopted by a region at the next safepoint → ticket dropped → unloaded at a safepoint. Two failure modes are handled explicitly, because both were caught in testing and both are silent when they go wrong:
- A ticket can vanish while the chunk is still generating (a player crosses a chunk boundary faster than generation completes). Such chunks are queued for unload on arrival instead of becoming permanently resident with no owner.
- A region whose last chunk unloads is retired — but any entity still in it is re-homed first. A player moving faster than chunks generate can empty their entire old view disc in one batch, and without re-homing they would land in a dead region, stop ticking, and never be seen again.
Identical workload, same machine (12 cores), only engine.region-threads changed. 10 bots scattered
6000 blocks apart → 10 independent regions, ~2958 chunks loaded, engine.synthetic-tick-load-micros = 8000 so each region tick costs a realistic 8 ms.
region-threads |
Slowest region TPS | Per-region MSPT | Peak regions ticking at once |
|---|---|---|---|
| 1 | 10.4 | 8.20 | 1 / 1 |
| 12 | 20.0 | 8.08 | 12 / 12 |
10 regions × 8 ms = 80 ms of work per 50 ms budget. One thread cannot keep up and falls to half rate; twelve threads absorb it and hold the 20 TPS ceiling with headroom to spare.
The synthetic load exists because this core implements no block behaviour, so a real tick is nearly
free — which would make a fast serial loop indistinguishable from true parallelism. parallel= and
peak= in the metrics line count regions actually inside a tick simultaneously; a peak of 1 means
the threading bought nothing.
Reproduce:
java -cp build/libs/quasar-0.1.0-all.jar dev.quasar.bench.BotSwarm --count 10 --spread 6000 --walk-seconds 10 --seconds 40The bots start together at spawn and walk apart, which exercises both halves of the region graph:
one shared region at the start that has to split as they separate. Pass --teleport true to jump
instead.
quasar.properties, rewritten with all defaults on first run.
| Key | Default | Notes |
|---|---|---|
server.port |
25565 | |
server.view-distance |
8 | Each player's disc is (2n+1)² chunks |
server.compression-threshold |
256 | 0 disables compression |
server.online-mode |
false | true is rejected at startup |
world.generator |
noise | noise or flat |
world.min-y / world.height |
-64 / 384 | Must match the dimension registry entry |
engine.region-threads |
cores | Region tick workers |
engine.worldgen-threads |
cores/2 | Chunk generation pool |
engine.metrics-interval-seconds |
30 | 0 disables the summary line |
engine.synthetic-tick-load-micros |
0 | Benchmarking only |
Targets Minecraft 1.21.4 (protocol 769).
Mojang renumbers play-phase packets on almost every release, and those numbers are the single most
likely thing here to be wrong for your client build. They are all gathered in one file —
net/Protocol.java — so a mismatch is a one-file fix rather than a hunt, and every one can be
overridden without recompiling by dropping a protocol.properties next to the jar:
play.clientbound.login = 0x2C
play.clientbound.chunk_data = 0x27Two other version-sensitive tables:
world/block/Blocks.java— global-palette block state IDs, overridable viablocks.properties. OnlyAIR(0) andSTONE(1) are reliable across versions; the rest are best-effort.net/registry/Registries.java— the dimension, biome, damage-type, wolf and painting registries the client demands during configuration. Entry names matter more than their contents: a client accepts bland values but not a missing key it expects to resolve.
Don't hand-write these — generate them. Mojang's server jar emits the exact tables:
java -jar server.jar --reportsgenerated/reports/packets.json— every packet with its phase, direction and protocol IDgenerated/reports/blocks.json— every block state; use the one flagged"default": truegenerated/reports/registries.json— registry contents
It only writes files and exits; no EULA, no server started. Get the jar for a specific version from
https://launchermeta.mojang.com/mc/game/version_manifest_v2.json → the version's metadata URL →
downloads.server.url. These generated files are deliberately gitignored rather than committed.
engine/ Region, RegionManager, RegionScheduler, TickMetrics — the core
net/ Netty pipeline, protocol constants, packet listeners per state
listener/ Handshake → Status → Login → Configuration → Play
registry/ Datapack registries sent during configuration
world/ Chunk, ChunkSection, World, ticket system, generators
entity/ Entity, Player
nbt/ NBT tree model and network serialiser
bench/ BotSwarm load harness
Sections start uniform — one state, no backing array — and only materialise storage on the first differing write, so the mostly-air-or-stone sections of a generated world stay cheap.
The full plan through to production lives in ROADMAP.md. Its critical path, in order:
- Enforced ownership.
assertOwned()exists but is barely called; today the no-locks guarantee rests on review discipline rather than on a check that fails loudly. Cheapest item on the path, and it protects every phase after it. - A light engine, so caves and interiors are not uniformly bright — and region-safe, meaning light updates cross a boundary only through a mailbox.
- Block physics: gravel and sand fall, water and lava flow.
- Basic redstone, which needs a clear answer to which region owns a component.
- Crafting, without which survival is impossible.
- Persistence gaps: item entities across chunk unload, and reading arbitrary vanilla worlds.
- A region-aware plugin and event API, then basic mobs and combat.
Smaller things worth doing along the way: signs, a safepoint-duration metric, and making
LINK_RADIUS configurable.
Worlds are written in Mojang's Anvil format: <level-name>/region/r.<rx>.<rz>.mca, plus a
level.dat. Third-party tools (Amulet, MCA Selector, NBTExplorer) read them, and the folder can be
dropped into .minecraft/saves.
Chunk NBT carries DataVersion 4189 — read out of version.json in the 1.21.4 client jar
(world_version), not guessed. Block palettes use names and properties from Mojang's blocks.json
report. Section indices are packed 64 / bits per long without straddling, with
bits = max(4, ceil(log2(paletteSize))); the floor of four is not optional.
Only edited chunks are saved. Generation is deterministic, so an untouched chunk costs nothing to store and is simply regenerated — a world stays proportional to what was built, not to where people walked. Loading checks disk first, so an edited chunk returns as it was left.
The chunk's NBT is built at a safepoint, where nothing can be mid-tick over it; deflating and writing
happen on the IO thread. A save therefore costs a pause proportional to how much was edited and none
proportional to disk speed. Chunks are saved on unload, on a timer
(world.autosave-interval-seconds), on save from the console, and on shutdown.
Item entities are written to vanilla's own entities/ region files, beside region/ — the same
file format, a different tree, which is where Minecraft has kept entities since 1.17. Writing them
into the chunk instead would have worked here and quietly lost them for anything else that opened
the world, which is the opposite of what Anvil support is for.
Saved at a safepoint just before a chunk is dropped, while the owning region still holds them, and restored when the chunk comes back. Age survives the round trip, so a stack saved four minutes into its life still despawns on time rather than getting a fresh five.
Falling blocks are not persisted: one exists for well under a second, and it is a transitional state rather than something a world contains.
Position, rotation and hotbar are written to <level-name>/playerdata/<uuid>.dat as gzipped NBT —
the same place and encoding a vanilla server uses. Only a subset of vanilla's fields is written;
anything else it expects is absent and filled with defaults. The hotbar is this server's own idea
and lives under a namespaced key rather than vanilla's Inventory, which has a different structure
and would be misread.
Saved when a player leaves and on every world save, loaded on join, with world spawn as the fallback for someone who has never played. Snapshots are built on the thread that owns the player, so positions are consistent, and only the finished NBT crosses to the IO thread.
Writes go through a temporary file and an atomic move. This file is rewritten constantly, and a crash midway through a plain write would truncate it and lose the player entirely; a failed move just leaves the previous one.
Because only edited chunks are stored, a world opened in Minecraft would otherwise show a handful of
saved chunks surrounded by whatever vanilla's own generator produces. The console command saveall
writes every currently loaded chunk instead, edited or not, turning the view distance around the
players online into a world someone else can walk around in:
saveall # with someone standing where you want the export centred
stop
Then copy <level-name>/ into .minecraft/saves/. The bot harness can hold that position for you:
--stay true keeps bots wherever the server spawned them rather than scattering.
Heightmaps are recomputed on load rather than trusted from disk — they are a pure function of the block data, so deriving them removes any chance of drift. Same for a section's non-air census.
Anvil identifies blocks by name and properties, so a numeric state ID needs translating. A built-in table covers every block this server generates or places, which is all that writing its own worlds requires.
Reading an arbitrary vanilla world needs the whole registry — some 28,000 states, which is Mojang's
data and is not shipped here. Drop a blocks.json from --reports next to the jar and the full
table loads at startup.
Without it, a chunk containing unknown blocks still loads, with those blocks standing in as stone — but the chunk is flagged and never written back. The same applies to chunks carrying block entities or scheduled ticks, which this server cannot represent. Degrading someone's world on disk because a lookup table was missing would be far worse than declining to save it.
- Terrain comes from this server's own noise generator, not Mojang's. Chunks it saved load back exactly; anything vanilla generates beyond them follows vanilla's generator and will not line up at the seams.
- No light data is written, so vanilla relights on load.
- Only the overworld exists.
Players spawn on dry land, found by walking a coarse grid outward from the origin until a column's surface clears sea level. Origin is a poor default — with a noise generator the terrain there is as likely as not to sit under water, and spawning submerged makes every attempted placement target water instead of air. The search generates no chunks; it only evaluates the generator's height function, which is pure noise.
Every item in the creative menu can be picked and placed — 1,385 items, 952 of which are blocks.
The server sends none of that. The creative inventory is built client-side: the client already
knows every item and renders the whole menu without asking. All the server has to do is listen. When
a player picks something the client reports it in set_creative_mode_slot, and from then on the only
question is which block a given item ID places.
Almost always, the one whose name matches — minecraft:oak_stairs the item places
minecraft:oak_stairs the block. A handful disagree (redstone → redstone_wire, carrot →
carrots) and are listed in ItemRegistry.NAME_OVERRIDES. Items with no block — swords, food —
place nothing.
Blocks are placed in the state the click implies, not their default one:
| property | derived from | affects |
|---|---|---|
axis |
the clicked face | logs, pillars, chains |
half / type |
clicked face, and cursor height on it | stairs, slabs, trapdoors |
facing |
the player's yaw | stairs, furnaces, observers |
waterlogged |
whether the space held water | anything waterloggable |
Only properties the block's default state actually has are touched, and the adjusted combination is
looked up by name — if it does not exist, the default is used. That fallback is what makes this safe
across the whole block set without a per-block table: type means top/bottom on a slab but
single/left/right on a chest, and asking for a chest of type "bottom" simply fails the lookup and
leaves the chest alone.
Stairs face the way the player looks, so you walk up them going forwards; almost everything else faces back towards the player so its front is the side you can see.
Not handled: blocks whose facing comes from the clicked face rather than the player — wall torches, buttons, levers — keep their default state. Slabs do not merge into double slabs.
Some properties come from a block's neighbours rather than from the click, and have to be recomputed on both sides whenever anything changes nearby. Placing or breaking a block re-derives that position and its four horizontal neighbours:
- fences, glass panes, iron bars —
north/south/east/westbooleans - walls —
none/low/tallper side, plus a raiseduppost unless it is a straight run through - stairs —
shape, giving inner and outer corners - chests —
type, pairing adjacent chests into a double chest
Stair corners follow vanilla exactly, including the rule that suppresses a corner when the neighbour is a stair of the same facing and half, so straight runs stay straight.
Fences match on the exact material, so oak does not join nether brick. Joining to ordinary solid blocks is an approximation: neither the block report nor the protocol carries collision or shape data, so solidity is inferred from the block's name against a curated list of decorative and non-collidable families. Everything unmatched counts as solid, which is right far more often than not; the failure mode is a fence connecting to something decorative it should have ignored.
Updates stop at the edge of the region that owns the chunk, so a connection there is left stale rather than computed by reaching into another thread's state.
Middle-click puts the block you are looking at into the held hotbar slot. Resolved by block name, so it works on any state — middle-clicking east-facing stairs hands you the stairs item rather than failing because only the default state was mapped.
Vanilla in creative hunts for a slot already holding that item and switches to it. With no real inventory to search, this overwrites the held slot, which is what a creative player is after: pick, then place.
blocks.json and registries.json from java -jar server.jar --reports are read from the working
directory at startup — 27,866 block states and 1,385 items in about 360 ms. They are Mojang's data,
so they are loaded at runtime rather than committed here.
Without them the server falls back to the nine-block starter hotbar and says so at startup, and Anvil support narrows to the blocks this server generates.
A client holding nothing does not send use_item_on — right-clicking with an empty hand is inert,
so no amount of server-side logic makes placing work. With no inventory system, nothing would ever
fill that hand. So on join the server pushes a fixed nine-block hotbar
(HotbarKit) as one Set Container Content on
window 0.
The pairing is the important part. Each entry ties an item ID to the block state that item places, and item N sits in hotbar slot N while slot N places block N. That way the client's optimistic prediction and the server's write agree, so placement neither flickers nor rolls back. A mismatched table would reintroduce exactly the desync the pairing exists to prevent.
Both ID spaces come from Mojang's reports — registries.json under minecraft:item for item IDs,
blocks.json for default block states — and both are overridable in hotbar.properties as
<name> = <itemId>:<blockState>.
Chests, barrels, hoppers, dispensers, droppers and shulker boxes open, hold items and persist.
Two adjacent chests facing the same way pair into a double chest and open as one 54-slot screen. The
pairing is derived from neighbours like any other connection state: a left half's partner sits
clockwise of its facing, a right half's counter-clockwise. A partner must be unpaired or already
pointing back, so a row of three chests cannot leave a half claimed by both its neighbours — a state
vanilla never produces and the client renders wrongly.
Each half keeps its own block entity on disk, with the window's slots laid end to end across the two. That is what lets vanilla read the two chests back independently.
Block entities are stored per chunk and written into Anvil's block_entities list verbatim,
id and coordinates included. Storing them untouched is what lets a chunk carrying block entities
this server does not model — spawners, signs, beehives — be loaded, held and written back unchanged
rather than dropped. That is also why chunks with block entities are no longer loaded read-only;
only scheduled ticks still block a save, because nothing here can run them and writing the chunk
back without them would quietly cancel whatever they were going to do.
A block entity is discarded when its block is replaced, compared by block name rather than state: a chest gaining a connection changes state without becoming a different block, and dropping its contents for that would be a data-loss bug.
Which blocks are containers is a curated table. Nothing in the generated reports says which blocks have a block entity, let alone how many slots it has. Furnaces, brewing stands and enchanting tables are deliberately absent — their screens have fuel, progress and result slots whose behaviour is not just "move items around", and a half-implemented furnace would be worse than none.
Window slots past the container map onto the player's real inventory rather than onto a copy, so the two cannot disagree. Handled:
| mode | what it is |
|---|---|
| 0 | pick up and place, right-click for half-stacks and single items |
| 1 | shift-move, merging into matching stacks before filling empty ones |
| 2 | number keys 1-9, swapping the slot with that hotbar slot |
| 5 | drag-painting: hold the button and sweep to spread a stack across slots |
| 6 | double-click to gather matching stacks onto the cursor |
Drag-painting arrives as three separate packets — start, one per slot swept, then end — all mode 5, distinguished by the button field. Nothing is applied until the end, because how the stack splits depends on how many slots were swept in total. A left drag divides evenly, a right drag puts one in each, and a middle drag fills each from an inexhaustible cursor, which is creative-only.
Every container update carries an incrementing revision. The client echoes the last one it saw on each click and uses it to tell whether its prediction is still in step; sending a constant made every update look like the same revision, which is indistinguishable from a stale client.
The whole window is re-sent after every click rather than sending per-slot deltas. Container clicks have a lot of cases, and any disagreement between what the client predicted and what the server did leaves items visibly duplicated or missing until something else resyncs. A full resync costs a few hundred bytes and makes that class of bug impossible.
Clicking outside the window keeps the stack on the cursor instead of dropping it: there are no item entities here, so a "drop" would silently destroy it. Closing a container with something on the cursor puts it back in the inventory for the same reason.
Breaking a container hands its contents to whoever broke it. Vanilla drops them on the ground, which needs item entities this server does not have — so the alternative was deleting them, which is silent and unrecoverable. If the inventory is full the remainder is lost, and says so in the log rather than vanishing quietly.
Item stacks carry an ID and a count and nothing else. Enchantments, damage and custom names are component data this server does not model, and pretending to would mean dropping them on the first click.
Players see each other: spawns, movement, head rotation and despawn, plus tab-list entries.
Only players in the same region are considered, and that is exact rather than a shortcut. Two players close enough to see each other have overlapping chunk discs, and overlapping discs are one region by construction; players in different regions are at least two view distances apart, well beyond tracking range. So tracking runs entirely on the owning thread with no cross-region reads — the same invariant that makes block edits lock-free.
Movement is sent as a delta, which the protocol encodes as a short of 1/4096 blocks and so tops out at eight. A larger jump is sent as a despawn followed by a respawn rather than reaching for the teleport packet, which was reworked in 1.21.2 and is one more thing to get wrong.
Tab-list entries are managed separately from entity spawn: the entity comes and goes with range, but the tab entry lasts as long as the player is online, or the list would flicker as people walk in and out of view. Skins are not sent — that needs signed profile properties from Mojang's session servers, which an offline-mode server has no way to obtain.
Dropped stacks are real entities that fall, merge and are picked up.
ItemEntity is deliberately small: it falls
straight down under gravity until a block is underneath, absorbs same-item stacks within one block,
lets any player within 1.6 blocks collect it, and despawns after 6000 ticks (five minutes, as in
vanilla) with a 20-tick delay before it can be regained.
They arrive from four routes — throwing with Q, dropping the cursor stack outside the window, overflow when a container gives you more than fits, and the contents of a container you break.
No horizontal motion, on purpose. Vanilla throws items in an arc and lets them bounce and slide, which needs real collision shapes. There are none here, and a half-simulated arc that clipped through walls would be worse than a stack that drops straight down.
An item never changes its horizontal position, so it stays in the chunk it was dropped into and can
never need state another region owns. That means the whole thing runs on the owning region's thread
with the same zero-lock guarantee as everything else — merges and pickups are plain loops over
region.entities().
Two honest limits. Items are not persisted: when their chunk unloads they are discarded rather than saved, and unlike a player they do not force a chunk to stay loaded. Persisting them without that second rule would mean every stack anyone ever threw held a chunk resident forever after the last player left. And breaking a block does not drop the block — players are in creative, where vanilla drops nothing either. Container contents do drop, because vanilla drops those even in creative.
Add Entity carries no item stack, so a drop is invisible until its metadata arrives; the tracker
sends set_entity_data (index 8, serializer 7) right after the spawn and again whenever a merge or
partial pickup changes the count.
Placing a sign opens the editor, and what you type is stored on the block entity and shown to
everyone who can see it. Text goes out as a single block_entity_data packet rather than a chunk
resend.
The one detail worth stating: since 1.20.4 a sign holds front_text and back_text, each with four
messages, and each message is a JSON text component serialised as a string — not a bare
string. Getting that wrong gives a sign that saves, loads and renders perfectly blank, because the
client parses each entry and silently draws nothing when it will not parse. The escaping is done by
hand here, since this is the only JSON the server writes and a quote typed onto a sign would
otherwise break the component.
Grass creeps onto bare dirt that has light and a grass neighbour, and dies back to dirt under anything solid. Three positions per section per tick, as vanilla does, in the chunks around a player rather than across everything loaded.
An optimisation that was removed again, because it is a useful warning: an early version examined only a quarter of the chunks per tick, on a benchmark that seemed to show random ticks costing two milliseconds a region. Re-running with random ticks disabled entirely gave a worse figure than leaving them on — so the two milliseconds were run-to-run variance on this machine, and the optimisation was justified by noise. It was deleted rather than kept just in case. This machine swings between 18.4 and 20.0 TPS on identical runs, which is worth knowing before reading anything into a single measurement here.
All 932 vanilla crafting recipes work at a crafting table: shaped and shapeless, ingredient tags, patterns matched anywhere in the grid and mirrored, shift-click to craft repeatedly.
This is the one piece of game data Mojang's --reports does not emit. Recipes live as ~1400
individual files in the vanilla data pack inside the jar, so they are extracted rather than
written from memory — the same rule as every other version-specific table here:
java -jar build/libs/quasar-0.1.0-all.jar --extract-recipes path/to/minecraft-1.21.4-client.jarThat writes recipes.json: 932 crafting recipes, with the other 438 (smelting, stonecutting,
smithing) skipped because a crafting grid cannot produce them. Without the file, crafting is
disabled and says so at startup. A hand-written partial table would be worse than none, because the
missing half looks like a bug rather than an absence.
Item tags are flattened at extraction. A recipe asks for #minecraft:planks, and tags nest —
#minecraft:logs contains #minecraft:logs_that_burn. Resolving that once, at extraction, leaves
the server's matcher dealing only in concrete item IDs.
Shapeless recipes match by multiset. Shaped ones are trimmed to their occupied rectangle first, so a 2×2 pattern works in any corner of a 3×3 grid, and are tried mirrored as well as normal — both are vanilla behaviour, and a player notices immediately when they are missing.
A recipe naming an item this server does not know is dropped whole at load rather than half-loaded, so it can never match something it should not.
- Crafting table only. The 2×2 grid in the player's own inventory is not implemented — and it is not reachable anyway, because players are in creative, where that screen is the creative menu.
- No recipe book. The client's book will not suggest anything; you have to know the recipe.
- No special recipes. Firework, banner and shulker-dyeing recipes are code in vanilla rather
than data, and the 32
crafting_transmuterecipes are excluded with them. - No drag-painting inside the crafting window. Containers support it; this does not yet, and an unhandled click resyncs rather than desynchronises.
Registries load in an order that matters: a recipe resolves every ingredient to an item ID as it
loads, so loading recipes before items dropped all 932 as "unknown items" and left crafting quietly
disabled — the server started fine and said Loaded 0 crafting recipes. It now loads items first,
and the log line names the number dropped so the same failure cannot be silent again.
Closing the screen — or disconnecting with a full grid — hands everything back. That second path was missing at first: the player was saved before the grid was returned, so logging out mid-craft saved an inventory that did not contain the materials, and they ceased to exist.
Dust carries power and loses a level per block, levers switch, torches invert, repeaters delay and re-emit, and lamps light. Right-clicking a lever flips it; right-clicking a repeater steps its delay.
The roadmap called this the hard question. The answer turned out to be that no component needs an owner, because no component is ever an object. There is no circuit graph, no network registry, nothing spanning chunks that would have to belong to somebody: every value is derived from the neighbours of a single block. That is the same shape as fluid flow, and a neighbour is at most one chunk away, so any loaded chunk it reaches is in the same region by construction.
A circuit is unbounded — a repeater chain can run for thousands of blocks — and that is fine for the same reason a river is: it advances a block per update and re-queues, and cannot outrun the loaded, connected chunks that make up its region.
Modelling a "network" object spanning the wire and giving it an owner would have created exactly the cross-region shared mutable state this engine exists not to have. Keeping the derivation local is what makes redstone free of coordination rather than the hardest thing to coordinate.
Dust is instant, as in vanilla: a wire carries at most 15 blocks, so the recomputation is bounded and finishes inside the tick that caused it rather than visibly charging up. Torches and repeaters introduce the delay, and they schedule their flip for a future tick of the owning region — so a clock's rate is measured in that region's ticks and stays correct when a region runs behind.
A block change wakes everything within two blocks, and the check for "is any of this redstone" runs
constantly. Doing that with name comparisons cost a full TPS on a world containing no redstone at
all. Both the wake test and the chunk-adoption scan now index a per-state boolean[], and a
redstone-free world is back to 20.0 TPS at 2.85 MSPT with 1734 chunks loaded.
All four came from someone building on the server, and none of them could have been caught by an assertion on the server's own state — which is the point of keeping a real client in the loop.
- Falling blocks were invisible. Gravity worked perfectly server-side; the entity tracker had
an allow-list of entity types and falling blocks were not on it, so sand appeared to vanish and
silently reappear somewhere below. The server log said
lost its support and is fallingwhile the player saw a block delete itself. - Redstone rendered as unconnected dots. Wire carries
powerand four connection properties, and onlypowerwas ever set. Every level was right and the circuit was inert to look at, because a wire with all four sidesnonedraws as an isolated speck. - Chests were invisible. The chunk packet sent a hard-coded zero block entities. A chest's block model is empty — the whole chest is drawn by a block-entity renderer — so a chest in a loaded chunk was a hole in the air that still opened when clicked.
- Chests rendered black once visible. Fixing (3) exposed this immediately: the light engine treats anything not in its transparency list as a full cube, so a chest zeroed the sky light at its own position — the exact value its renderer uses.
Sand also rested on redstone dust instead of falling through it, which is why gravity now asks
BlockCollision.isPassable rather than "is this air or water".
- No pistons, comparators, observers, dispensers or droppers. Those are the interesting half of redstone and none of them are here.
- No buttons or pressure plates. Levers are the only manual input.
- Simplified power model. Vanilla distinguishes strong from weak power in ways this does not, so some quasi-connectivity and block-powering edge cases will behave differently.
- No torch burnout, so a torch in a fast loop will not stop.
- Needs
blocks.json. The built-in block table has no redstone in it, so without Mojang's generated report redstone is simply inert rather than wrong.
Seven tests assert power levels and lit flags — redstone that does nothing leaves a perfectly valid world behind, so "it ran without throwing" proves nothing. Stubbing out dust propagation fails five of them. One test runs a wire across a chunk boundary, which is the region-safety claim in practice rather than in prose.
There is deliberately no scripted-client check. The lesson from the physics scenario stands: mouse aim cannot reliably hit a specific block face, and a check that cannot hit its target is worse than no check.
Sand and gravel fall when their support goes. Water and lava flow, thin out with distance, fall before they spread, and drain when their source is removed.
One update step moves a block or a fluid level exactly one block, so it only ever touches the six
neighbours of a position. A neighbour is at most one chunk away, and any loaded chunk one away is
in the same region — regions are the connected components of "within LINK_RADIUS chunks", so two
adjacent loaded chunks cannot belong to different ones.
Unlike light, a cascade is unbounded: water can run for hundreds of blocks. That is still safe, because it advances one step per update and re-queues. The frontier can only reach chunks that are loaded and connected, which is the same region by construction; where the loaded world ends, the flow stops, as it does in vanilla.
Queue entries can still go stale if a region splits between queueing and processing. Rather than re-homing the queue, each position is checked against the owning region and dropped if the chunk changed hands — the new owner re-seeds its own chunks on adoption.
Unloaded chunks read as stone, not air, through World.getBlockRaw. getBlock answers air,
which is right for rendering and badly wrong here: air is "replaceable", so water would pour into
unloaded terrain and sand would fall into it, and both writes would then be silently dropped.
Water updates every 5 ticks and lava every 30, matching vanilla, which is what makes a stream creep rather than appear. Gravity is checked every tick. Everything drains under a budget of 2048 positions per region tick, so a large flood defers instead of spiking.
- No source forming. Two adjacent sources over a solid block do not create a third, so infinite water pools do not work.
- Fluid interaction is partial. Lava meeting water becomes obsidian (a source) or cobblestone (flowing), as in vanilla. Nothing catches fire, and water flowing onto lava from above does not make stone.
- Falling blocks do not break anything. They land on the first non-replaceable block; they do not destroy torches or crops on the way, and they do not hurt anyone.
- Only sand and gravel fall. Concrete powder and anvils behave the same way in vanilla and are not listed.
Cost with six bots and 1751 loaded chunks: worst MSPT 3.09, a full 20.0 TPS, up from 1.90 before physics.
The behaviour is proved by unit tests and by RegionTickTest, which runs a real region tick and
asserts sand falls, water spreads, and the chunk gets lit — the tick path itself, not just the
algorithms.
The scripted-client scenario deliberately asserts nothing about gravity. Placing a block against
a specific face needs the crosshair on that face, and scripted mouse-look could not hit it reliably:
three attempts produced three different misses, and one of them "passed" while placing sand on flat
ground, where it is supported and can never fall. That is the same false-confidence trap as the
pick-block check that passed without a middle-click. pwsh -File tools/client-test.ps1 -Scenario physics is there to look at, not to assert on.
Sky and block light propagate properly: caves are dark, overhangs cast shade, a torch lights a room, and breaking the block that caps a shaft lets daylight fall to the floor.
Light is capped at 15 and loses at least one level per block, so a change influences blocks at most 15 away. A section is 16 wide. Fifteen blocks from the very edge of a chunk therefore lands in the adjacent chunk and cannot reach the one beyond it — the blast radius of any light update is the 3x3 chunks around it.
The engine already guarantees chunks within LINK_RADIUS (2) of each other share a region. 1 < 2,
so every chunk a light update can touch is already owned by the region doing the update. Light needs
no mailbox and no ownership transfer; it falls out of the same geometry that makes block editing
lock-free.
The roadmap assumed light would need an explicit mailbox. It does not, and that is worth stating
precisely rather than quietly enjoying: the guarantee depends on two constants. Raising the maximum
light level, shrinking a section, or dropping LINK_RADIUS to 1 turns light into a silent
cross-region data race. LightEngineTest.lightCannotReachBeyondTheAdjacentChunk asserts the
arithmetic so that change fails loudly instead.
The queue, though, is per-region rather than per-world. Two regions ticking in parallel would
otherwise push and pop one shared queue — precisely the shared mutable state the engine exists not
to have. World.setBlock finds the right one through Region.current(), a thread-local the tick
loop maintains.
Propagation is breadth-first and drained under a budget (DEFAULT_BUDGET, 8192 positions per region
tick). A cascade — breaking the one block that opens a long cave to daylight — spreads over several
ticks instead of spiking one. A region's light cost is bounded by the budget, never by the size of
the cascade. Work left over is deferred, never dropped, which a test pins by draining a large
propagation through a deliberately tiny budget.
Light is 4 bits per block: a section is 2048 bytes, and a chunk with sky and block light for 26 sections would be ~106 KB. A few thousand chunks would be hundreds of megabytes of near-identical bytes — pitch black underground, full daylight above.
So a light section holds either a real array or a single uniform value, and only materialises when something inside it actually varies. Generation fills every section above the terrain as uniform daylight without allocating anything.
- Occlusion is by block name, not by shape. Vanilla decides from collision shape, so a slab blocks light from below but not the side. There are no shapes here, so a curated set of names lets light through and everything else stops it. Light does not leak past slabs, fences or stairs the way it should.
- Emission is a hand-written table. Mojang's generated block report carries every state and its properties but not luminance, so there is nothing to read it from. The listed blocks are right; an unlisted emitter reads as dark.
- No daylight cycle. Sky light is computed at full strength; there is no night, so sky light never dims.
Cost measured with six bots and 1734 loaded chunks: worst MSPT 1.90, a full 20.0 TPS.
An invariant held up only by careful review is a habit, not an invariant. When the single-writer rule breaks the symptom is never an exception — it is a block that reverts, an inventory that duplicates, a chunk that saves half-written. Those corrupt silently, surface far from the cause, and barely reproduce. This is the difference between "we are careful" and "the engine will not let you".
Checks come in two tiers:
- Always on. Comparing a thread reference is free, so anything that only asks "do I own the
region I am already holding" runs in production too:
Region.assertOwned(),assertOwnedOrSafepoint(), and every*AtSafepointstructural change. - Strict mode. Checks that must first work out which region owns a coordinate need a lookup
under a lock, far too costly for a path that runs on every block read. Those are gated on
engine.strict-ownership, which defaults toauto— on under--debug, off otherwise.
Three kinds of access are legal without owning anything, and the list is deliberately short: generation and loading (the chunk has no owning region yet, so nothing can be racing it), safepoints (no region is ticking, and the dispatcher marks itself), and startup/shutdown.
The errors are meant to be actionable, so the wording is asserted by tests rather than left to drift:
Ownership violation: Writing a block at 5,70,5 (chunk 0,0) is owned by region #1,
but was called from thread 'intruder'. Post the work to that region instead:
region.post(() -> ...).
A violation inside a tick is caught and logged by Region.runTick, so one bad region cannot take
the server down — which also means a lone stack trace could scroll past unnoticed. The count is
therefore carried on every metrics line as OWNERSHIP-VIOLATIONS=n once non-zero.
OwnershipTest deliberately breaks the rule
from a foreign thread and asserts it is caught. That is the point of it: an assertion that has never
fired is indistinguishable from one that cannot fire, and this project has been burnt by exactly
that twice — a bot swarm that ran green against a fully submerged world, and a pick-block check that
passed without a single middle-click ever being handled.
So the guards were verified by sabotage, not by observing green. Stubbing out the block check fails exactly the two block-access tests; stubbing out the safepoint check fails exactly the two safepoint tests. A test that cannot fail is not evidence.
There is also a test pinning the fact that strict mode off skips the expensive lookup — so anyone making these checks unconditional has to change a test and decide knowingly, rather than accidentally putting a locked lookup in a hot path.
tools/client-test.ps1 drives a real vanilla 1.21.4 client end to end:
it launches the instance through PrismLauncher, waits for the join, injects real keyboard and mouse
input, asserts on what the server logged, and reports any decode error the client wrote.
pwsh -File tools/client-test.ps1
This exists because the bot swarm cannot fail the way a client can. It shares the server's own constants and has no prediction logic, so an entire class of bug is invisible to it by construction -- it ran green for hours against a world that was completely underwater. Every protocol and prediction bug in this project so far was found by a human driving a real client. This automates that.
No account is involved: Prism's --offline flag invents a throwaway profile, which is all an
offline-mode server needs, so the launcher's stored session is never touched.
The scenario walks, looks, breaks, places, pick-blocks, drops, opens the inventory and takes an in-game screenshot, then checks the server log for each resulting action and scans the client log for decode errors and new disconnect reports. It runs on a superflat world on purpose: on noise terrain the spawn is wherever the generator puts it, and the first run walked into an ocean and "broke" water, failing for reasons unrelated to the code under test.
Limits worth knowing. Input injection needs the game in the foreground, so a run takes over the
screen for about a minute. Assertions are only as good as their patterns -- the first version of the
pick-block check matched picked up 1 x item 1 from an item entity and passed without a single
middle-click ever being handled, which is the same false-confidence trap the bot swarm falls into.
Anchor patterns on something only the code path under test can produce.
Breaking and placing land on the owning region's thread and touch nothing else. That falls directly
out of the engine's invariant rather than from any locking: a player's view disc is always contained
in a single region, so every player who could witness a block change is an entity of the same
region already running the edit. The broadcast is a plain loop over region.entities().
Placement replaces air and water, matching vanilla. Restricting it to air alone meant every right-click below sea level was silently refused, which on screen is indistinguishable from a placement that never arrived — the block just flashes and vanishes.
Three guards apply before a write, in Player.isEditAllowed:
- Reach — beyond ~8 blocks the edit is dropped. Without it the packet is a remote world-edit primitive for anyone who can open a socket.
- Ownership — a position outside the region's own chunks belongs to another thread and is refused, which keeps the single-writer rule true by construction rather than by convention.
- Build height.
A rejected edit is still acknowledged, and the authoritative state at that position is re-read and sent afterwards, so a refused edit corrects the client rather than leaving its prediction standing.
Send the ack before the block update, not after. The client places optimistically and waits on its sequence number; the ack retires that prediction and snaps the position back to whatever server state it last knew. Sending the update first requires the client to correctly associate an in-flight update with a pending prediction, and it does not — placements rolled back to air, while breaks appeared to work only because the client predicts a break to air anyway. Acking first and then asserting the truth makes the result independent of that association. This is the kind of bug the bot harness cannot catch: both orderings look identical from a client that has no prediction logic.