Hi Polkadot community ![]()
I’ve been deep in Substrate for a while now, but I kept running into the same wall: sure, I could use the SDK, spin up the node template, throw in some pallets, and even get a working chain out the door… but I still didn’t really understand how the consensus stuff actually works under the hood.
I already know the theory. I just really wanted to see it actually work from first principles. So I stripped everything back and built a little Consensus Lab from scratch nothing fancy, no magic, just the exact same DNA as Substrate (SCALE codec + Blake3 hashing). Now I can literally watch every single byte move around in real time.
Why the Black-Box Approach Wasn’t Enough
Most Substrate tutorials start with substrate-node-template and cargo expand on the macros. That’s great for speed, but terrible for intuition. You end up treating sp_consensus_babe and sp_consensus_grandpa like black boxes.
When you’re trying to build production logic or debug why your chain behaves strangely under network stress, a black box isn’t enough. I wanted to feel the data flow, the determinism, the probabilistic lottery, and see exactly when and why a fork is born.
So I built a tiny discrete-event simulator in pure Rust. No Substrate runtime, no FRAME, no #[pallet]. Just the primitives that actually matter for consensus.
Architecture
The project is deliberately structured like a miniature Polkadot SDK:
primitives/— Zero-dependency protocol types (Header, Block, Hash, Extrinsic)core/runtime/— State Transition Function (STF) + deterministic state rootsconsensus/— BABE-style slot-based leadership + Longest-Chain fork selectionnode/— Actor that handles block import, proposal, and re-orgs
network/— Discrete-event message simulator with configurable propagation latency
Everything is built for maximum fidelity:
- Parity SCALE Codec everywhere
- Blake3 for hashing (same as Substrate)
- Deterministic state roots via sorted
BTreeMap+ SCALE encoding - Probabilistic slot claiming (VRF-style approximation)
The “Eureka” Moment: Slot Collisions in Real Time
I ran a 3-node simulation for 20 slots with ~33% slot-claim probability per validator (realistic BABE-style threshold) and 1-slot network latency.
Here’s exactly what the logs showed (real output):
[INFO] ---------------- Slot 1 ----------------
[INFO] [node_0] ⚡ Proposed block at height 1 (hash: d30c23..b9a2)
[INFO] ---------------- Slot 2 ----------------
[INFO] [node_1] ⚡ Proposed block at height 2 (hash: 6518e1..1d2f)
[INFO] [node_2] ⚡ Proposed block at height 2 (hash: e977a5..107e)
[INFO] ---------------- Slot 4 ----------------
[INFO] [node_0] ⚡ Proposed block at height 3 (hash: 5db3af..c748)
[INFO] [node_2] ⚡ Proposed block at height 3 (hash: 4c2d21..2fe7)
[INFO] ---------------- Slot 7 ----------------
[INFO] [node_0] ⚡ Proposed block at height 5 (hash: 99172d..1588)
[INFO] [node_2] ⚡ Proposed block at height 5 (hash: a2fd36..61b1)
... (more collisions in slots 9, 10, 12, 18, 20)
[INFO] Simulation complete.
[INFO] Node node_0 canonical head: 1803eb..c69d (Blocks discovered: 24)
[INFO] Node node_1 canonical head: 13fd3e..f8bf (Blocks discovered: 25)
[INFO] Node node_2 canonical head: 0eca66..24df (Blocks discovered: 25)
This is the anatomy of a fork in action.
In Slot 2, two nodes both won the lottery → two cryptographically valid blocks at the same height. Because of the 1-slot latency, they arrived out of order on different nodes. Forks were born instantly. The longest-chain rule kicked in independently on each node. By the end of 20 slots the network had produced 25 blocks (instead of 20) and the nodes were still sitting on slightly different canonical heads.
In a real Substrate chain you’d never see this in the logs just a quiet re-org. In my terminaI i watched it in slow motion with full visibility into every node’s block DAG.
That single run taught me more about fork resolution than three months of reading the BABE paper.
How the Core Pieces Actually Work
Deterministic State Roots
pub fn root(&self) -> Hash {
let bytes = self.encode(); // BTreeMap is sorted → always identical
Hash::from_bytes(blake3::hash(&bytes).into())
}
Probabilistic Slot Claiming (BABE-style lottery)
pub fn claim_slot(&self, slot: Slot, randomness: [u8; 32]) -> bool {
let mut rng = ChaCha20Rng::from_seed(seed);
let val: u64 = rng.gen();
val < self.threshold
}
Longest-Chain Fork Choice
pub fn find_best_head<'a>(&self, headers: &'a [Header]) -> Option<&'a Header> {
headers.iter().max_by_key(|h| (h.number, -(h.slot as i64)))
}
Network Simulator every block gets a configurable propagation delay so forks can actually stay alive.
What This Taught Me
- Slot collisions aren’t bugs — they’re a feature of probabilistic finality.
- Network latency is the real enemy of fast convergence.
- Determinism is everything — state roots must match on every node or the chain can never heal.
- Longest-chain heals forks, but it takes time — and sometimes leaves temporary divergence.
- GRANDPA exists for a reason — longest-chain alone is not enough for strong finality.
What’s Next (still building live)
- Real gossip topology with neighbor meshes (no more instant broadcast)
- Configurable network partitions
- Upgrade to proper GHOST fork-choice
- Full VRF + signature verification
- GRANDPA-style finality gadget integration
Why This Matters
If you’re serious about building on Polkadot/Substrate, understanding consensus at this level changes everything. You stop treating the chain as magic and start seeing it as a deterministic, probabilistic, eventually-consistent distributed system.
I’m still learning, still iterating, but this little lab has already given me more intuition than any tutorial ever could.
Would love your feedback especially if you’ve built similar minimal simulators or have ideas for the next experiments.
Drop a comment if you want the repo link (happy to share once it’s cleaned up a bit more). Or if you want to pair on the next iteration I’m all in.
Happy forking! ![]()
All code is open for inspection and can be ported back into real pallets.
Repo structure
primitives/ → types, Header, Block, Hash
core/
├── runtime/ → STF + deterministic state roots
├── consensus/ → BABE lottery + fork choice
└── node/ → block import, proposal, re-org logic
network/ → discrete-event latency simulator