Godot 4.3 2D Exploration Board Game

I’m working on a multi-player 2D board game that players similar to board games like Betrayl at house on the hill. I don’t have any pictures yet to show as I’m in the early stages of coding still. But wanted to share the dice script singleton I wrote in hopes it could help people out. Its right below… I’ll try and post an update when I get a working test scene!

extends Node
## Global dice roller singleton
## Supports:
## - arbitrary dice (roll(n, sides))
## - named dice (d4, d6, d8, d10, d12, d20, d100)
## - percentile (int + float version)
## - advantage / disadvantage
## - history with timestamps
## - last-roll inspection
## Drop this in Autoloads as "DiceRoller"

#region Signals --------------------------------------------------------

## Emitted every time a roll happens.
## result     = final total (after advantage/disadvantage applied)
## num_dice   = how many dice were rolled
## sides      = sides per die
## roll_type  = DiceRoller.RollType.*
signal dice_rolled(result: int, num_dice: int, sides: int, roll_type: int)

#endregion

#region Enums ----------------------------------------------------------

## How to roll each die:
## NONE          → roll once
## ADVANTAGE     → roll twice, keep highest
## DISADVANTAGE  → roll twice, keep lowest
enum RollType { NONE, ADVANTAGE, DISADVANTAGE }

#endregion

#region Variables ------------------------------------------------------

## RNG used for all rolls (can be seeded)
var rng: RandomNumberGenerator = RandomNumberGenerator.new()

## Stores the dice values from the *most recent* roll
## Example: [4, 6, 2]
var last_rolls: Array[int] = []

## Stores *all* rolls in this session
## key: int (auto-increment)
## value: {
##   "num_dice": int,
##   "sides": int,
##   "rolls": Array[int],
##   "total": int,
##   "roll_type": int,
##   "time": Dictionary (from Time.get_datetime_dict_from_system())
## }
var roll_history: Dictionary = {}

#endregion

#region Lifecycle ------------------------------------------------------

func _ready() -> void:
	## Randomize once on startup
	rng.randomize()

#endregion

#region Core Roll Logic ------------------------------------------------

## Main roll function.
## Rolls `num_dice` of `sides`, applying roll_type (none/adv/dis).
## Returns the TOTAL of all dice.
func roll(num_dice: int, sides: int, roll_type: int = RollType.NONE) -> int:
	# clear previous roll
	last_rolls.clear()

	# roll each die
	for i in range(num_dice):
		match roll_type:
			RollType.NONE:
				last_rolls.append(rng.randi_range(1, sides))
			RollType.ADVANTAGE:
				# roll twice, take higher
				var a := rng.randi_range(1, sides)
				var b := rng.randi_range(1, sides)
				last_rolls.append(max(a, b))
			RollType.DISADVANTAGE:
				# roll twice, take lower
				var c := rng.randi_range(1, sides)
				var d := rng.randi_range(1, sides)
				last_rolls.append(min(c, d))

	# get total for this roll
	var total: int = get_last_rolls_total()

	# notify listeners (UI, logs, etc.)
	dice_rolled.emit(total, num_dice, sides, roll_type)

	# store in history
	add_to_history(num_dice, sides, last_rolls.duplicate(), total, roll_type)

	return total

## Adds a completed roll to the history dictionary.
func add_to_history(num_dice: int, sides: int, rolls: Array[int], total: int, roll_type: int = RollType.NONE) -> void:
	roll_history[roll_history.size()] = {
		"num_dice": num_dice,
		"sides": sides,
		"rolls": rolls,
		"total": total,
		"roll_type": roll_type,
		"time": Time.get_datetime_dict_from_system()
	}

#endregion

#region History Access -------------------------------------------------

## Get a roll by index (safe).
## If index out of range or history empty → returns empty dict {}.
func get_roll_from_history(ind: int) -> Dictionary:
	if roll_history.is_empty():
		return {}
	ind = clamp(ind, 0, roll_history.size() - 1)
	return roll_history[ind]

## Returns the *most recent* roll entry from history.
func get_last_roll() -> Dictionary:
	if roll_history.is_empty():
		return {}
	return roll_history[roll_history.size() - 1]

## Convert roll_type → string for UI/logging.
## NONE → "" ; ADVANTAGE → "adv" ; DISADVANTAGE → "dis"
func get_roll_type_as_string(roll_type: int) -> String:
	match roll_type:
		RollType.NONE:
			return ""
		RollType.ADVANTAGE:
			return "adv"
		RollType.DISADVANTAGE:
			return "dis"
	return ""

## Get the last N rolls, NEWEST first.
## Returns a Dictionary: 0 → newest, 1 → 2nd newest, ...
func get_roll_history_range_from_end(search_length: int) -> Dictionary:
	var history: Dictionary = {}
	var total_entries := roll_history.size()
	if total_entries == 0:
		return history

	# clamp requested length
	if search_length > total_entries:
		search_length = total_entries

	var out_index := 0
	# walk backwards through history
	for i in range(total_entries - 1, total_entries - search_length - 1, -1):
		history[out_index] = roll_history[i]
		out_index += 1
	return history

## Get the first N rolls, OLDEST first.
## Returns a Dictionary: 0 → oldest, 1 → 2nd oldest, ...
func get_roll_history_range_from_start(search_length: int) -> Dictionary:
	var history: Dictionary = {}
	var total_entries := roll_history.size()
	if total_entries == 0:
		return history

	if search_length > total_entries:
		search_length = total_entries

	for i in range(search_length):
		history[i] = roll_history[i]
	return history

#endregion

#region Helpers --------------------------------------------------------

## Sum up the current last_rolls array.
## Called after every roll.
func get_last_rolls_total() -> int:
	var total := 0
	for roll in last_rolls:
		total += roll
	return total

#endregion

#region Named Dice Rolls -----------------------------------------------

## All of these just call the main roll(...) with appropriate sides.

func roll_d4(num_dice: int = 1, roll_type: int = RollType.NONE) -> int:
	return roll(num_dice, 4, roll_type)

func roll_d6(num_dice: int = 1, roll_type: int = RollType.NONE) -> int:
	return roll(num_dice, 6, roll_type)

func roll_d8(num_dice: int = 1, roll_type: int = RollType.NONE) -> int:
	return roll(num_dice, 8, roll_type)

func roll_d10(num_dice: int = 1, roll_type: int = RollType.NONE) -> int:
	return roll(num_dice, 10, roll_type)

func roll_d12(num_dice: int = 1, roll_type: int = RollType.NONE) -> int:
	return roll(num_dice, 12, roll_type)

func roll_d20(num_dice: int = 1, roll_type: int = RollType.NONE) -> int:
	return roll(num_dice, 20, roll_type)

func roll_d100(num_dice: int = 1, roll_type: int = RollType.NONE) -> int:
	return roll(num_dice, 100, roll_type)

#endregion

#region Percentile Rolls -----------------------------------------------

## “Integer percentile” → standard 1..100 roll
func roll_ipercentile(roll_type: int = RollType.NONE) -> int:
	return roll(1, 100, roll_type)

## “Float percentile” → 0.01..1.00
func roll_percentile(roll_type: int = RollType.NONE) -> float:
	return roll(1, 100, roll_type) / 100.0

#endregion

#region Extra Helpers --------------------------------------------------

## Roll with a bonus/penalty applied to the final total
func roll_with_modifier(num_dice: int, sides: int, modifier: int = 0, roll_type: int = RollType.NONE) -> int:
	return roll(num_dice, sides, roll_type) + modifier

## Set a specific RNG seed (for deterministic tests / replays)
func set_seed(seed_value: int) -> void:
	rng.seed = seed_value

## Randomize RNG again
func get_new_seed() -> void:
	rng.randomize()

#endregion