From 1fc98174574e175e2dcf7e7499e3812e5142853f Mon Sep 17 00:00:00 2001 From: davesmith00000 Date: Tue, 12 Nov 2024 00:41:31 +0000 Subject: [PATCH] Dice uses Xorshift algo, not scala.util.Random --- .../benchmarks/PhysicsWorldBenchmarks.scala | 2 +- .../pathfinding/PathFindingTests.scala | 10 +- .../scala/indigo/gameengine/GameEngine.scala | 2 +- .../main/scala/indigo/shared/Context.scala | 13 +- .../main/scala/indigo/shared/dice/Dice.scala | 145 ++++++++++++++++-- .../shared/datatypes/PointSpecification.scala | 8 +- .../shared/datatypes/SizeSpecification.scala | 8 +- .../scala/indigo/shared/dice/DiceTests.scala | 96 +++++++++++- .../main/scala/example/IndigoPhysics.scala | 2 +- .../example/sandbox/scenes/BoxesScene.scala | 2 +- 10 files changed, 246 insertions(+), 42 deletions(-) diff --git a/indigo/benchmarks/src/main/scala/indigo/benchmarks/PhysicsWorldBenchmarks.scala b/indigo/benchmarks/src/main/scala/indigo/benchmarks/PhysicsWorldBenchmarks.scala index 87d9d2130..6f94a692c 100644 --- a/indigo/benchmarks/src/main/scala/indigo/benchmarks/PhysicsWorldBenchmarks.scala +++ b/indigo/benchmarks/src/main/scala/indigo/benchmarks/PhysicsWorldBenchmarks.scala @@ -50,7 +50,7 @@ object PhysicsWorldBenchmarks: object TestWorlds: - val dice: Dice = Dice.fromSeed(0) + val dice: Dice = Dice.default val basicWorld: World[MyTag] = val circles = diff --git a/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFindingTests.scala b/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFindingTests.scala index d1bf4ef16..d2f95d5b6 100644 --- a/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFindingTests.scala +++ b/indigo/indigo-extras/src/test/scala/indigoextras/pathfinding/PathFindingTests.scala @@ -21,7 +21,7 @@ class PathFindingTests extends munit.FunSuite { val searchGrid = SearchGrid.generate(start, end, List(impassable), 3, 3) - val path: List[Coords] = searchGrid.locatePath(Dice.fromSeed(0)) + val path: List[Coords] = searchGrid.locatePath(Dice.default) val possiblePaths: List[List[Coords]] = List( List(start, Coords(2, 2), Coords(1, 2), end), @@ -45,7 +45,7 @@ class PathFindingTests extends munit.FunSuite { val searchGrid = SearchGrid.generate(start, end, List(impassable), 3, 3) - val path: List[Coords] = searchGrid.locatePath(Dice.fromSeed(0)) + val path: List[Coords] = searchGrid.locatePath(Dice.default) val possiblePaths: List[List[Coords]] = List( List(start, Coords(0, 2), Coords(1, 2), Coords(2, 2), end), @@ -67,7 +67,7 @@ class PathFindingTests extends munit.FunSuite { val searchGrid = SearchGrid.generate(start, end, List(impassable), 3, 3) - val path: List[Coords] = searchGrid.locatePath(Dice.fromSeed(0)) + val path: List[Coords] = searchGrid.locatePath(Dice.default) val possiblePaths: List[List[Coords]] = List( List(start, Coords(2, 0), Coords(2, 1), Coords(2, 2), end), @@ -89,7 +89,7 @@ class PathFindingTests extends munit.FunSuite { val searchGrid = SearchGrid.generate(start, end, List(impassable), 3, 3) - val path: List[Coords] = searchGrid.locatePath(Dice.fromSeed(0)) + val path: List[Coords] = searchGrid.locatePath(Dice.default) val possiblePaths: List[List[Coords]] = List( List(start, Coords(2, 2), Coords(2, 1), Coords(2, 0), end), @@ -111,7 +111,7 @@ class PathFindingTests extends munit.FunSuite { val searchGrid = SearchGrid.generate(start, end, List(impassable), 3, 3) - val path: List[Coords] = searchGrid.locatePath(Dice.fromSeed(0)) + val path: List[Coords] = searchGrid.locatePath(Dice.default) val possiblePaths: List[List[Coords]] = List( List(start, Coords(2, 2), Coords(1, 2), Coords(0, 2), end), diff --git a/indigo/indigo/src/main/scala/indigo/gameengine/GameEngine.scala b/indigo/indigo/src/main/scala/indigo/gameengine/GameEngine.scala index 5799dd5c8..3eaf2f796 100644 --- a/indigo/indigo/src/main/scala/indigo/gameengine/GameEngine.scala +++ b/indigo/indigo/src/main/scala/indigo/gameengine/GameEngine.scala @@ -178,7 +178,7 @@ final class GameEngine[StartUpData, GameModel, ViewModel]( audioPlayer.addAudioAssets(accumulatedAssetCollection.sounds) - val dice = Dice.fromSeed((if firstRun then 0 else gameLoopInstance.runningTimeReference).toLong) + val dice = if firstRun then Dice.default else Dice.fromSeed(gameLoopInstance.runningTimeReference.toLong) if firstRun then platform = new Platform(parentElement, gameConfig, globalEventStream, dynamicText) diff --git a/indigo/indigo/src/main/scala/indigo/shared/Context.scala b/indigo/indigo/src/main/scala/indigo/shared/Context.scala index 218b690a5..0db19f012 100644 --- a/indigo/indigo/src/main/scala/indigo/shared/Context.scala +++ b/indigo/indigo/src/main/scala/indigo/shared/Context.scala @@ -67,7 +67,7 @@ object Context: ), Services( boundaryLocator, - scala.util.Random(), + scala.util.Random(Dice.DefaultSeed), _captureScreen ) ) @@ -98,7 +98,7 @@ object Context: new Frame(dice, time, input) val initial: Frame = - new Frame(Dice.fromSeed(0), GameTime.zero, InputState.default) + new Frame(Dice.default, GameTime.zero, InputState.default) trait Services: def bounds: Services.Bounds @@ -180,7 +180,8 @@ object Context: def nextString(length: Int): String def nextPrintableChar(): Char def setSeed(seed: Long): Unit - def shuffle[T](xs: List[T]): List[T] + def shuffle[A](xs: List[A]): List[A] + def shuffle[A](xs: Batch[A]): Batch[A] def alphanumeric(take: Int): List[Char] object Random: @@ -201,7 +202,8 @@ object Context: def nextString(length: Int): String = _random.nextString(length) def nextPrintableChar(): Char = _random.nextPrintableChar() def setSeed(seed: Long): Unit = _random.setSeed(seed) - def shuffle[T](xs: List[T]): List[T] = _random.shuffle(xs) + def shuffle[A](xs: List[A]): List[A] = _random.shuffle(xs) + def shuffle[A](xs: Batch[A]): Batch[A] = Batch.fromList(_random.shuffle(xs.toList)) def alphanumeric(take: Int): List[Char] = _random.alphanumeric.take(take).toList val noop: Random = @@ -220,7 +222,8 @@ object Context: def nextString(length: Int): String = "" def nextPrintableChar(): Char = ' ' def setSeed(seed: Long): Unit = () - def shuffle[T](xs: List[T]): List[T] = xs + def shuffle[A](xs: List[A]): List[A] = xs + def shuffle[A](xs: Batch[A]): Batch[A] = xs def alphanumeric(take: Int): List[Char] = List.fill(take)(' ') trait Screen: diff --git a/indigo/indigo/src/main/scala/indigo/shared/dice/Dice.scala b/indigo/indigo/src/main/scala/indigo/shared/dice/Dice.scala index 62bea348c..d44046f1d 100644 --- a/indigo/indigo/src/main/scala/indigo/shared/dice/Dice.scala +++ b/indigo/indigo/src/main/scala/indigo/shared/dice/Dice.scala @@ -6,7 +6,6 @@ import indigo.shared.time.Millis import indigo.shared.time.Seconds import scala.annotation.tailrec -import scala.util.Random /** The Dice primitive supplies a consistent way to get psuedo-random values into your game. * @@ -20,6 +19,9 @@ trait Dice: /** The seed value of the dice. The dice supplied in the `FrameContext` has the seed set to the current running time * of the game in milliseconds. + * + * If the seed value is 0, it is replaced with the `DefaultSeed` value in order for the underlying PRNG to work + * correctly. */ def seed: Long @@ -31,6 +33,14 @@ trait Dice: */ def roll(sides: Int): Int + /** Roll a Long from 1 to the number of sides on the dice (inclusive) + */ + def rollLong: Long + + /** Roll a Long from 1 to the specified number of sides (inclusive), using this dice instance as the seed. + */ + def rollLong(sides: Int): Long + /** Roll an Int from 0 to the number of sides on the dice (inclusive) */ def rollFromZero: Int @@ -67,14 +77,21 @@ trait Dice: */ def shuffle[A](items: List[A]): List[A] - def shuffle[A](items: Batch[A]): Batch[A] = - Batch.fromSeq(shuffle(items.toList)) + /** Shuffles a Batch of values into a random order + */ + def shuffle[A](items: Batch[A]): Batch[A] override def toString: String = - s"Dice(seed = ${seed.toString()})" + val seedValue = + if seed == 0L then s"${seed.toString} (substituted with ${Dice.DefaultSeed.toString})" + else seed.toString + + s"Dice(seed = $seedValue)" object Dice: + val DefaultSeed: Long = 42L + /** Construct a 'max int' sided dice using a time in seconds (converted to millis) as the seed. */ def fromSeconds(time: Seconds): Dice = @@ -87,10 +104,17 @@ object Dice: /** Construct a 'max int' sided dice from a given seed value. This is the default dice presented by the * `FrameContext`. + * + * The implementation of this method uses the Xorshift algorithm to generate random numbers, which has a problem: A + * seed value of 0 will produce a value of 0. This is a known issue with the algorithm, and while it is not a bug, it + * will cause unexpected behaviour. Hence, if the seed value is 0, it is replaced with the `DefaultSeed` value. */ def fromSeed(seed: Long): Dice = Sides.MaxInt(seed) + def default: Dice = + Sides.MaxInt(DefaultSeed) + private val isPositive: Int => Boolean = _ > 0 @@ -127,7 +151,7 @@ object Dice: */ def loaded(fixedTo: Int): Dice = new Dice { - val seed: Long = 0 + val seed: Long = Dice.DefaultSeed def roll: Int = fixedTo @@ -135,6 +159,12 @@ object Dice: def roll(sides: Int): Int = fixedTo + def rollLong: Long = + fixedTo.toLong + + def rollLong(sides: Int): Long = + fixedTo.toLong + def rollFromZero: Int = fixedTo @@ -158,22 +188,27 @@ object Dice: def shuffle[A](items: List[A]): List[A] = items + + def shuffle[A](items: Batch[A]): Batch[A] = + items } /** Constructs a dice with a given number of sides and a seed value. */ def diceSidesN(sides: Int, seedValue: Long): Dice = new Dice { - val seed: Long = seedValue - val r: Random = new Random(seed) + // The Xorshift algorithm requires a seed value that is not 0, as a seed of 0 will produce a value of 0. + val seed: Long = if seedValue == 0L then DefaultSeed else seedValue + + private val r: RandomImpl = new RandomImpl(seed.toInt) /** Roll an Int from 1 to the number of sides on the dice (inclusive) * * @return */ def roll: Int = - roll(sides) + r.nextInt(sides) + 1 /** Roll an Int from 1 to the specified number of sides (inclusive) * @@ -181,7 +216,17 @@ object Dice: * @return */ def roll(sides: Int): Int = - r.nextInt(sanitise(sides)) + 1 + r.nextInt(sides) + 1 + + /** Roll a Long from 1 to the number of sides on the dice (inclusive) + */ + def rollLong: Long = + r.nextLong(sides) + 1 + + /** Roll a Long from 1 to the specified number of sides (inclusive), using this dice instance as the seed. + */ + def rollLong(sides: Int): Long = + r.nextLong(sides) + 1 /** Roll an Int from 0 to the number of sides on the dice (exclusive) * @@ -196,7 +241,7 @@ object Dice: * @return */ def rollFromZero(sides: Int): Int = - roll(sides) - 1 + r.nextInt(sides) /** Roll an Int from the range provided (inclusive) * @@ -229,7 +274,7 @@ object Dice: * @return */ def rollAlphaNumeric(length: Int): String = - r.alphanumeric.take(length).mkString + r.alphanumeric(length) /** Produces a random alphanumeric string 16 characters long * @@ -238,13 +283,21 @@ object Dice: def rollAlphaNumeric: String = rollAlphaNumeric(16) - /** Shuffles a list of values into a random order + /** Shuffles a Batch of values into a random order * * @param items * @return */ - def shuffle[A](items: List[A]): List[A] = + def shuffle[A](items: Batch[A]): Batch[A] = r.shuffle(items) + + /** Shuffles a List of values into a random order + * + * @param items + * @return + */ + def shuffle[A](items: List[A]): List[A] = + r.shuffle(Batch.fromList(items)).toList } /** Pre-constructed dice with a fixed number of sides, rolls are includive and start at 1, not 0. You need to provide @@ -319,3 +372,69 @@ object Dice: /** A sixteen-sided dice. */ def Sixteen(seed: Long): Dice = diceSidesN(16, seed) + + /** A simple random number generator based on the Xorshift algorithm. + */ + @SuppressWarnings(Array("scalafix:DisableSyntax.var")) + final class RandomImpl(_seed: Int) { + + private var seed: Int = _seed + + // Xorshift for 32-bit integers, the backbone of this random number generator + def nextInt(): Int = { + var x = seed + x ^= x << 13 + x ^= x >>> 17 + x ^= x << 5 + seed = x + x + } + + // Generate an Int up to a limit value + def nextInt(limit: Int): Int = + Math.abs(nextInt()) % limit + + // Generate a Long by combining two nextInt() calls + def nextLong(): Long = + (nextInt().toLong << 32) | (nextInt().toLong & 0xffffffffL) + + // Generate a Long up to a limit value + def nextLong(limit: Int): Long = + Math.abs(nextLong()) % limit + + // Generates a Float between 0.0 and 1.0 + // Use of the bit mask ensures the result is positive while preserving the number distribution. + def nextFloat(): Float = + (nextInt().toLong & 0xffffffffL) / (1L << 32).toFloat + + // Generates a Double between 0.0 and 1.0 + def nextDouble(): Double = + (nextLong() & Long.MaxValue) / (Long.MaxValue.toDouble + 1) + + // Generates a Boolean + def nextBoolean(): Boolean = + nextInt() % 2 == 0 + + // Fisher-Yates shuffle for List[A] + // More efficient but less readable than `items.sortBy(_ => nextInt())` + def shuffle[A](items: Batch[A]): Batch[A] = + val array = items.toJSArray + + for (i <- array.indices.reverse) { + val j = nextInt().abs % (i + 1) + val temp = array(i) + array(i) = array(j) + array(j) = temp + } + + Batch(array) + + // Generate an alphanumeric string of a given length + def alphanumeric(limit: Int): String = + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + (1 to limit).map(_ => chars(nextInt().abs % chars.length)).mkString + + // Method to reset the seed for reproducibility + def setSeed(newSeed: Int): Unit = + seed = newSeed + } diff --git a/indigo/indigo/src/test/scala/indigo/shared/datatypes/PointSpecification.scala b/indigo/indigo/src/test/scala/indigo/shared/datatypes/PointSpecification.scala index 6a1d854b7..d86c8227e 100644 --- a/indigo/indigo/src/test/scala/indigo/shared/datatypes/PointSpecification.scala +++ b/indigo/indigo/src/test/scala/indigo/shared/datatypes/PointSpecification.scala @@ -6,14 +6,14 @@ import org.scalacheck._ class PointSpecification extends Properties("Dice") { property("all random values are within the max range and >= 0") = Prop.forAll(Gen.choose(0, 500)) { max => - val dice = Dice.fromSeed(0) + val dice = Dice.default val value = Point.random(dice, max) value.x >= 0 && value.y >= 0 && value.x <= max && value.y <= max } property("all random values are within the max range (Point) and >= 0") = Prop.forAll(Gen.choose(0, 500)) { max => - val dice = Dice.fromSeed(0) + val dice = Dice.default val value = Point.random(dice, Point(max)) value.x >= 0 && value.y >= 0 && value.x <= Point(max).x && value.y <= Point(max).y @@ -21,7 +21,7 @@ class PointSpecification extends Properties("Dice") { property("all random values are within the min / max range") = Prop.forAll(Gen.choose(-500, 0), Gen.choose(0, 500)) { (min, max) => - val dice = Dice.fromSeed(0) + val dice = Dice.default val value = Point.random(dice, min, max) value.x >= min && value.y >= min && value.x <= max && value.y <= max @@ -29,7 +29,7 @@ class PointSpecification extends Properties("Dice") { property("all random values are within the min / max range (Point)") = Prop.forAll(Gen.choose(-500, 0), Gen.choose(0, 500)) { (min, max) => - val dice = Dice.fromSeed(0) + val dice = Dice.default val value = Point.random(dice, Point(min), Point(max)) value.x >= Point(min).x && value.y >= Point(min).y && value.x <= Point(max).x && value.y <= Point(max).y diff --git a/indigo/indigo/src/test/scala/indigo/shared/datatypes/SizeSpecification.scala b/indigo/indigo/src/test/scala/indigo/shared/datatypes/SizeSpecification.scala index 2b1a1462f..420f3c8da 100644 --- a/indigo/indigo/src/test/scala/indigo/shared/datatypes/SizeSpecification.scala +++ b/indigo/indigo/src/test/scala/indigo/shared/datatypes/SizeSpecification.scala @@ -6,14 +6,14 @@ import org.scalacheck._ class SizeSpecification extends Properties("Dice") { property("all random values are within the max range and >= 0") = Prop.forAll(Gen.choose(0, 500)) { max => - val dice = Dice.fromSeed(0) + val dice = Dice.default val value = Size.random(dice, max) value.width >= 0 && value.height >= 0 && value.width <= max && value.height <= max } property("all random values are within the max range (Size) and >= 0") = Prop.forAll(Gen.choose(0, 500)) { max => - val dice = Dice.fromSeed(0) + val dice = Dice.default val value = Size.random(dice, Size(max)) value.width >= 0 && value.height >= 0 && value.width <= Size(max).width && value.height <= Size(max).height @@ -21,7 +21,7 @@ class SizeSpecification extends Properties("Dice") { property("all random values are within the min / max range") = Prop.forAll(Gen.choose(-500, 0), Gen.choose(0, 500)) { (min, max) => - val dice = Dice.fromSeed(0) + val dice = Dice.default val value = Size.random(dice, min, max) value.width >= min && value.height >= min && value.width <= max && value.height <= max @@ -29,7 +29,7 @@ class SizeSpecification extends Properties("Dice") { property("all random values are within the min / max range (Size)") = Prop.forAll(Gen.choose(-500, 0), Gen.choose(0, 500)) { (min, max) => - val dice = Dice.fromSeed(0) + val dice = Dice.default val value = Size.random(dice, Size(min), Size(max)) value.width >= Size(min).width && value.height >= Size(min).height && value.width <= Size( diff --git a/indigo/indigo/src/test/scala/indigo/shared/dice/DiceTests.scala b/indigo/indigo/src/test/scala/indigo/shared/dice/DiceTests.scala index a6be621a1..3612cab15 100644 --- a/indigo/indigo/src/test/scala/indigo/shared/dice/DiceTests.scala +++ b/indigo/indigo/src/test/scala/indigo/shared/dice/DiceTests.scala @@ -15,7 +15,7 @@ class DiceTests extends munit.FunSuite { def almostEquals(d: Double, d2: Double, p: Double) = (d - d2).abs <= p test("diceSidesN") { - val roll: Int = Dice.diceSidesN(1, 0).roll(10) + val roll: Int = Dice.diceSidesN(1, 1000).roll(10) assertEquals(checkDice(roll, 10), true) } @@ -38,29 +38,111 @@ class DiceTests extends munit.FunSuite { } test("should be able to produce an alphanumeric string") { - val dice = Dice.fromSeed(0) + val dice = Dice.default val actual = dice.rollAlphaNumeric - // Psuedorandom! Seed of 0 produces "CCzLNHBFHuRvbI1i" val expected = - "CCzLNHBFHuRvbI1i" + "IoLMAKmKvLY5MSfL" assertEquals(actual.length(), 16) assertEquals(actual, expected) } test("shuffle") { - val dice = Dice.fromSeed(0) + val dice = Dice.default val actual = dice.shuffle(List(1, 2, 3, 4, 5)) - // Psuedorandom! Seed of 0 produces List(5, 3, 2, 4, 1) val expected = - List(5, 3, 2, 4, 1) + List(2, 5, 4, 1, 3) assertEquals(actual.length, 5) assertEquals(actual, expected) } + test("should be able to produce boolean values") { + val dice = Dice.default + val actual = List.fill(10)(dice.rollBoolean) + + val expected: List[Boolean] = + List( + true, true, false, true, true, true, true, true, false, false + ) + + assertEquals(actual, expected) + } + + test("should be able to produce Int values") { + val dice = Dice.default + val actual = List.fill(10)(dice.roll) + + val expected: List[Int] = + List( + 11355433, 1458948949, 476557060, 646921281, 534983741, 1441438135, 581500457, 1863322963, 1174750318, 1067267640 + ) + + assertEquals(actual, expected) + } + + test("should be able to produce Long values") { + val dice = Dice.default + val actual = List.fill(10)(dice.rollLong) + + val expected: List[Long] = + List( + 711245566, 306192841, 1776012994, 878840226, 1282232996, 1380324889, 1361527385, 705894190, 1128366901, + 1044750824 + ) + + assertEquals(actual, expected) + } + + test("should be able to produce Double values") { + val dice = Dice.default + val actual = List.fill(10)(dice.rollDouble) + + val expected: List[Double] = + List( + 0.005287785390537222, 0.2219141739650522, 0.7508787830991721, 0.729217749352642, 0.4529642552363186, + 0.39186976607271856, 0.4249471892873046, 0.9117737604549992, 0.25252137333629515, 0.6249562369807594 + ) + + assertEquals(actual, expected) + } + + test("should be able to produce Float values") { + val dice = Dice.default + val actual = List.fill(10)(dice.rollFloat) + + val expected: List[Float] = + List( + 0.002643892541527748f, 0.6603119969367981f, 0.1109570860862732f, 0.849376916885376f, 0.8754394054412842f, + 0.335610955953598f, 0.864608883857727f, 0.5661613345146179f, 0.7264821529388428f, 0.24849261343479156f + ) + + def floatsEqual(a: Float, b: Float): Boolean = + Math.abs(a - b) < 0.0001 + + assert(actual.length == expected.length) + assert( + actual.zip(expected).forall { case (a, b) => + floatsEqual(a, b) + } + ) + } + + test("should be able to roll within a range") { + val dice = Dice.default + val actual = List.fill(10)(dice.rollRange(10, 20)) + + val expected: List[Int] = + List( + 10, 16, 10, 15, 15, 14, 19, 16, 14, 19 + ) + + assert(actual.forall(i => i >= 10 && i <= 20)) + assertEquals(actual, expected) + } + test("all dice rolls have an approximately uniform distribution") { val diceSides = 63 val numRuns = 200_000_000 diff --git a/indigo/physics/src/main/scala/example/IndigoPhysics.scala b/indigo/physics/src/main/scala/example/IndigoPhysics.scala index c24949936..b2dac3131 100644 --- a/indigo/physics/src/main/scala/example/IndigoPhysics.scala +++ b/indigo/physics/src/main/scala/example/IndigoPhysics.scala @@ -27,7 +27,7 @@ object IndigoPhysics extends IndigoGame[Unit, Unit, Model, Unit]: ) def initialModel(startupData: Unit): Outcome[Model] = - Outcome(Model.initial(Dice.fromSeed(0))) + Outcome(Model.initial(Dice.default)) def initialViewModel(startupData: Unit, model: Model): Outcome[Unit] = Outcome(()) diff --git a/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/BoxesScene.scala b/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/BoxesScene.scala index 39b192fbe..49c6d86eb 100644 --- a/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/BoxesScene.scala +++ b/indigo/sandbox/src/main/scala/com/example/sandbox/scenes/BoxesScene.scala @@ -61,7 +61,7 @@ object BoxesScene extends Scene[SandboxStartupData, SandboxGameModel, SandboxVie ) val shapes: Batch[Shape[?]] = - val d = Dice.fromSeed(0) + val d = Dice.default Batch.fromList( (1 to 30).toList.flatMap { _ => List(makeLine(d), makeBox(d))