SkollDice
← Back to Home

Proof of Functioning

Validating randomness, entropy distribution, and statistical fairness across all dice types.

📊 Entropic Dice Balance Validation

This project tests the fairness and distribution of a dice generator powered by hardware system entropy and custom post-balancing logic.

Below is the quick summary table showing the maximum recorded variance for each die type from the latest testing session (2026-08-05 18:59:18). All dice operate well within standard tolerance thresholds.

Die Type Total Sides Target Odds / Side Max Variance Verdict
D4 4 25.00% < ±0.01% Perfect (Fair) ✅
D6 6 16.67% < ±0.01% Perfect (Fair) ✅
D8 8 12.50% < ±0.01% Perfect (Fair) ✅
D10 10 10.00% < ±0.01% Perfect (Fair) ✅
D12 12 8.33% < ±0.00% Perfect (Fair) ✅
D20 20 5.00% < ±0.00% Perfect (Fair) ✅
D100 100 1.00% < ±0.00% Perfect (Fair) ✅

⚙️ Core Entropy Generator (`main.c`)

The core program reads high-entropy hardware random data from /dev/urandom and applies a modulo-bias elimination technique (simple discard method) to ensure uniformly distributed rolls across standard RPG dice types (D4 to D100).

#include <fcntl.h> #include <stdint.h> #include <stdio.h> #include <unistd.h> // Dice list typedef struct { int n; enum { D4 = 4, D6 = 6, D8 = 8, D10 = 10, D12 = 12, D20 = 20, D100 = 100 } dices_type; } Dices; // specifics about the code inside README and /include/src.h // Why the function is normally distributed explained inside the README // Inside /include/src.h is contained the Enum explanation uint8_t simple_discard_method(int divisor, FILE *rand_reader) { int limit = (256 / divisor) * divisor; uint8_t random_byte; do { fread(&random_byte, sizeof(uint8_t), 1, rand_reader); } while (random_byte >= limit); int result = (random_byte % divisor) + 1; return result; } int main() { FILE *rand_reader = fopen("/dev/urandom", "rb"); if (!rand_reader) { perror("Failed to open /dev/urandom"); return 1; } // number of dices thrown int number_of_dice = 0; // array with the number of each type of dice Dices types_and_number_of_dices[7] = { {100000, D4}, {100000, D6}, {100000, D8}, {100000, D10}, {100000, D12}, {100000, D20}, {100000, D100}}; int index_dices = 0; while (index_dices < 7) { int number_of_generations = types_and_number_of_dices[index_dices].n; printf("D%d: ", types_and_number_of_dices[index_dices].dices_type); for (int i = 0; i < number_of_generations; i++) { int value = simple_discard_method( types_and_number_of_dices[index_dices].dices_type, rand_reader); printf("%d ", value); } printf("\n"); index_dices++; } fclose(rand_reader); return 0; }

📝 Validation Engine & Source Code (`analyze.py`)

The statistical analyzer repeatedly executes the compiled binary, aggregates hundreds of thousands of rolls across all dice categories, runs Chi-Square goodness-of-fit tests, and exports structured results into CSV reports and Markdown documentation.

#!/usr/bin/env python3 """ Dice Balance Analyzer Calls ./main NUM_RUNS times, captures its stdout each time, accumulates all dice rolls, computes balance statistics, and exports a final CSV summary. Usage: python analyze.py python analyze.py --runs 200 python analyze.py --csv output.csv """ import sys import os import csv import subprocess from collections import defaultdict, Counter from datetime import datetime DEFAULT_RUNS = 100 WARN_THRESHOLD = 2.0 BAR_WIDTH = 20 DEFAULT_CSV = "dice_results.csv" SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) MAIN_EXE = os.path.join(SCRIPT_DIR, "main") MAX_FACES = {"D4": 4, "D6": 6, "D8": 8, "D10": 10, "D12": 12, "D20": 20, "D100": 100} def get_arg(flag: str, default: str | None = None) -> str | None: args = sys.argv[1:] if flag in args: idx = args.index(flag) if idx + 1 < len(args): return args[idx + 1] return default def get_runs() -> int: val = get_arg("--runs") try: return int(val) if val else DEFAULT_RUNS except ValueError: return DEFAULT_RUNS def get_csv_path() -> str: return get_arg("--csv") or DEFAULT_CSV def parse_output(raw: str) -> dict[str, list[int]]: result: dict[str, list[int]] = {} for line in raw.strip().splitlines(): line = line.strip() if not line or ":" not in line: continue label, _, values = line.partition(":") label = label.strip().upper() try: nums = [int(x) for x in values.split() if x.strip()] if nums: result[label] = nums except ValueError: pass return result def call_main(run_index: int) -> dict[str, list[int]] | None: try: proc = subprocess.run( [MAIN_EXE], capture_output=True, text=True, timeout=30, ) if proc.returncode != 0: return None parsed = parse_output(proc.stdout) if not parsed: return None return parsed except Exception: return None def collect_all_runs(num_runs: int) -> tuple[dict[str, Counter], int]: accumulated: dict[str, Counter] = defaultdict(Counter) success = 0 for i in range(1, num_runs + 1): run_data = call_main(i) if run_data is None: continue for die, rolls in run_data.items(): accumulated[die].update(rolls) success += 1 if success == 0: sys.exit(1) return accumulated, success def compute_stats(counts: Counter, max_face: int) -> dict[int, float]: total = sum(counts.values()) return { face: (counts.get(face, 0) / total * 100.0) if total else 0.0 for face in range(1, max_face + 1) } def chi_square(pct: dict[int, float], label: str) -> tuple[float, str]: n = len(pct) expected = 100.0 / n chi2 = sum((p - expected) ** 2 / expected for p in pct.values()) critical = {3: 7.81, 5: 11.07, 7: 14.07, 9: 16.92, 11: 19.68, 19: 30.14, 99: 123.22} df = n - 1 crit = critical.get(df, 3.841 * df) verdict = "BALANCED" if chi2 <= crit else "UNBALANCED" return chi2, verdict def build_csv_rows( label: str, max_face: int, pct: dict[int, float], counts: Counter, chi2: float, verdict: str, ts: str, ) -> list[dict]: expected = 100.0 / max_face rows = [] for face in range(1, max_face + 1): p = pct.get(face, 0.0) delta = p - expected rows.append( { "timestamp": ts, "die": label, "total_faces": max_face, "face": face, "total_rolls": counts.get(face, 0), "simulated_pct": round(p, 4), "expected_pct": round(expected, 4), "delta_pct": round(delta, 4), "chi2": round(chi2, 4), "balance": verdict, "anomaly_flag": "YES" if abs(delta) >= WARN_THRESHOLD else "NO", } ) return rows CANONICAL_ORDER = ["D4", "D6", "D8", "D10", "D12", "D20", "D100"] def main() -> None: num_runs = get_runs() csv_path = get_csv_path() accumulated, successful_runs = collect_all_runs(num_runs) keys = [k for k in CANONICAL_ORDER if k in accumulated] keys += [k for k in accumulated if k not in CANONICAL_ORDER] ts: str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") all_csv_rows: list[dict] = [] for label in keys: counts = accumulated[label] max_face = MAX_FACES.get(label, max(counts.keys())) pct = compute_stats(counts, max_face) chi2, verdict = chi_square(pct, label) all_csv_rows.extend( build_csv_rows(label, max_face, pct, counts, chi2, verdict, ts) ) file_exists = os.path.isfile(csv_path) with open(csv_path, "a" if file_exists else "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=[ "timestamp", "die", "total_faces", "face", "total_rolls", "simulated_pct", "expected_pct", "delta_pct", "chi2", "balance", "anomaly_flag" ]) if not file_exists: writer.writeheader() writer.writerows(all_csv_rows) if __name__ == "__main__": main()

📥 Download Source & Scripts

Download the required files to run local entropy and statistical validation:

How to run the validation:
  1. Download and compile the C source file via terminal: gcc main.c -o main -O3
  2. Ensure the compiled main executable is placed in the same directory as the Python scripts.
  3. Run the Python analyzer to gather data and generate the CSV report: python analyze.py --runs 50
  4. Generate the Markdown summary report with: python summary.py