Repository: ASPP/pelita

Issue:
Adopt SciPy SPEC 7 on Seeding Pseudo-Random Number Generation
This seems to be the new standard. I am not sure if we are already doing the right thing. We should check:

https://scientific-python.org/specs/spec-0007/

Hints from issue comments:
@otizonaizit: It seems to boil down to initialize the random generator with:

```python
import numpy as np
SEED = 12345
np.random.default_rng(SEED)
```

we are doing

```python
import random
SEED = 12345
random.Random(SEED)
```

is is worth to depend on numpy for this?@Debilski: We depend on numpy already (if only in the maze generator).

But as far as I understand it, the main idea is that they want to discourage re-seeding (which makes sense) in favour of generating a new RNG object with a given seed (which is then supposed to be passed as a context object to functions instead of relying on the global RNG). To get the same effect, I think we could simply change all occurrences of

    random.seed(SEED)

with

    rng = random.Random(SEED)

(+ streamline the code that we never rely on the global RNG either)@Debilski: We kind of do it already like this in the noiser in `gamestate_filters` btw.

Solution patch:
diff --git a/contrib/ci_engine.py b/contrib/ci_engine.py
index b92d45790..f68467a14 100755
--- a/contrib/ci_engine.py
+++ b/contrib/ci_engine.py
@@ -46,10 +46,10 @@
 import itertools
 import json
 import logging
-import random
 import sqlite3
 import subprocess
 import sys
+from random import Random
 
 import click
 from rich.console import Console
@@ -184,6 +184,7 @@ def start(self, n):
 
         """
         loop = itertools.repeat(None) if n == 0 else itertools.repeat(None, n)
+        rng = Random()
 
         for _ in  loop:
             # choose the player with the least number of played game,
@@ -193,9 +194,9 @@ def start(self, n):
             game_count = [(self.dbwrapper.get_game_count(p['name']), idx) for idx, p in enumerate(self.players)]
             players_sorted = [idx for count, idx in sorted(game_count) if not idx in broken_players]
             a, rest = players_sorted[0], players_sorted[1:]
-            b = random.choice(rest)
+            b = rng.choice(rest)
             players = [a, b]
-            random.shuffle(players)
+            rng.shuffle(players)
 
             self.run_game(players[0], players[1])
             self.pretty_print_results(highlight=[self.players[players[0]]['name'], self.players[players[1]]['name']])
diff --git a/pelita/base_utils.py b/pelita/base_utils.py
new file mode 100644
index 000000000..8e838b5d5
--- /dev/null
+++ b/pelita/base_utils.py
@@ -0,0 +1,13 @@
+from random import Random
+
+def default_rng(seed=None):
+    """Construct a new RNG from a given seed or return the same RNG.
+
+    Parameters
+    ----------
+    seed : Random | int | None
+        RNG to re-use or seed to initialise a new RNG or None to initialise a RNG without seed
+    """
+    if isinstance(seed, Random):
+        return seed
+    return Random(seed)
diff --git a/pelita/game.py b/pelita/game.py
index 89d62adee..add90fe52 100644
--- a/pelita/game.py
+++ b/pelita/game.py
@@ -2,7 +2,6 @@
 
 import logging
 import os
-from random import Random
 import subprocess
 import sys
 import time
@@ -14,6 +13,7 @@
 from .gamestate_filters import noiser, update_food_age, relocate_expired_food
 from .layout import initial_positions, get_legal_positions
 from .network import setup_controller, ZMQPublisher
+from .base_utils import default_rng
 from .team import make_team
 from .viewer import ProgressViewer, AsciiViewer, ReplyToViewer, ReplayWriter, ResultPrinter
 
@@ -88,7 +88,7 @@ def controller_exit(state, await_action='play_step'):
             return False
 
 def run_game(team_specs, *, layout_dict, layout_name="", max_rounds=300,
-             seed=None, allow_camping=False, error_limit=5, timeout_length=3,
+             rng=None, allow_camping=False, error_limit=5, timeout_length=3,
              viewers=None, viewer_options=None, store_output=False,
              team_names=(None, None), team_infos=(None, None),
              allow_exceptions=False, print_result=True):
@@ -119,8 +119,8 @@ def run_game(team_specs, *, layout_dict, layout_name="", max_rounds=300,
     max_rounds : int
               The maximum number of rounds to play before the game is over. Default: 300.
 
-    seed : int
-        seed used to initialize the random number generator.
+    rng : random.Random | int | None
+        random number generator or a seed used to initialize a new one.
 
     error_limit : int
                    The limit of non fatal errors to reach for a team before the
@@ -196,7 +196,7 @@ def run_game(team_specs, *, layout_dict, layout_name="", max_rounds=300,
                        layout_name=layout_name, max_rounds=max_rounds,
                        allow_camping=allow_camping,
                        error_limit=error_limit, timeout_length=timeout_length,
-                       seed=seed, viewers=viewers,
+                       rng=rng, viewers=viewers,
                        viewer_options=viewer_options,
                        store_output=store_output, team_names=team_names,
                        team_infos=team_infos,
@@ -271,7 +271,7 @@ def setup_viewers(viewers=None, options=None, print_result=True):
     return viewer_state
 
 
-def setup_game(team_specs, *, layout_dict, max_rounds=300, layout_name="", seed=None,
+def setup_game(team_specs, *, layout_dict, max_rounds=300, layout_name="", rng=None,
                allow_camping=False, error_limit=5, timeout_length=3,
                viewers=None, viewer_options=None, store_output=False,
                team_names=(None, None), team_infos=(None, None),
@@ -313,6 +313,8 @@ def setup_game(team_specs, *, layout_dict, max_rounds=300, layout_name="", seed=
 
     viewer_state = setup_viewers(viewers, options=viewer_options, print_result=print_result)
 
+    rng = default_rng(rng)
+
     # Initialize the game state.
 
     game_state = dict(
@@ -415,7 +417,7 @@ def setup_game(team_specs, *, layout_dict, max_rounds=300, layout_name="", seed=
         teams=[None] * 2,
 
         #: Random number generator
-        rnd=Random(seed),
+        rng=rng,
 
         #: Timeout length, int, None
         timeout_length=timeout_length,
@@ -534,7 +536,7 @@ def prepare_bot_state(game_state, idx=None):
         # We assume that we are in get_initial phase
         turn = idx
         bot_turn = None
-        seed = game_state['rnd'].randint(0, sys.maxsize)
+        seed = game_state['rng'].randint(0, sys.maxsize)
     elif bot_finalization:
         # Called for remote players in _exit
         turn = idx
@@ -555,7 +557,7 @@ def prepare_bot_state(game_state, idx=None):
                               enemy_positions=enemy_positions,
                               noise_radius=game_state['noise_radius'],
                               sight_distance=game_state['sight_distance'],
-                              rnd=game_state['rnd'])
+                              rng=game_state['rng'])
 
 
     # Update noisy_positions in the game_state
@@ -669,7 +671,7 @@ def prepare_viewer_state(game_state):
 
     # remove unserializable values
     del viewer_state['teams']
-    del viewer_state['rnd']
+    del viewer_state['rng']
     del viewer_state['viewers']
     del viewer_state['controller']
 
@@ -855,7 +857,7 @@ def apply_move(gamestate, bot_position):
         # There was an error for this round and turn
         # but the game is not over.
         # We execute a random move
-        bot_position = gamestate['rnd'].choice(legal_positions)
+        bot_position = gamestate['rng'].choice(legal_positions)
         game_print(turn, f"Setting a legal position at random: {bot_position}")
 
     # take step
diff --git a/pelita/gamestate_filters.py b/pelita/gamestate_filters.py
index b1621747b..6007bcf0c 100644
--- a/pelita/gamestate_filters.py
+++ b/pelita/gamestate_filters.py
@@ -1,8 +1,8 @@
 """ collecting the game state filter functions """
-import random
+from .base_utils import default_rng
 
 
-def noiser(walls, shape, bot_position, enemy_positions, noise_radius=5, sight_distance=5, rnd=None):
+def noiser(walls, shape, bot_position, enemy_positions, noise_radius=5, sight_distance=5, rng=None):
     """Function to make bot positions noisy in a game state.
 
     Applies uniform noise in maze space. Noise will only be applied if the
@@ -38,7 +38,7 @@ def noiser(walls, shape, bot_position, enemy_positions, noise_radius=5, sight_di
         the radius for the uniform noise
     sight_distance : int, optional, default: 5
         the distance at which noise is no longer applied.
-    rnd : Random, optional
+    rng : Random, optional
         the game’s random number generator (or None for an independent one)
 
     Returns
@@ -48,8 +48,7 @@ def noiser(walls, shape, bot_position, enemy_positions, noise_radius=5, sight_di
     """
 
     # set the random state
-    if rnd is None:
-        rnd = random.Random()
+    rng = default_rng(rng)
 
     # store the noised positions
     noised_positions = [None] * len(enemy_positions)
@@ -64,7 +63,7 @@ def noiser(walls, shape, bot_position, enemy_positions, noise_radius=5, sight_di
 
         if cur_distance is None or cur_distance > sight_distance:
             # If so then alter the position of the enemy
-            new_pos, noisy_flag = alter_pos(b, noise_radius, rnd, walls, shape)
+            new_pos, noisy_flag = alter_pos(b, noise_radius, rng, walls, shape)
             noised_positions[count] = new_pos
             is_noisy[count] = noisy_flag
         else:
@@ -74,7 +73,7 @@ def noiser(walls, shape, bot_position, enemy_positions, noise_radius=5, sight_di
     return { "enemy_positions": noised_positions, "is_noisy": is_noisy }
 
 
-def alter_pos(bot_pos, noise_radius, rnd, walls, shape):
+def alter_pos(bot_pos, noise_radius, rng, walls, shape):
     """ alter the position """
 
     # get a list of possible positions
@@ -110,7 +109,7 @@ def alter_pos(bot_pos, noise_radius, rnd, walls, shape):
         noisy = False
     else:
         # select a random position
-        final_pos = rnd.choice(possible_positions)
+        final_pos = rng.choice(possible_positions)
         noisy = True
 
     # return the final_pos and a flag if it is noisy or not
@@ -153,7 +152,7 @@ def relocate_expired_food(game_state, team, radius, max_food_age=None):
     food_age = [dict(team_food_age) for team_food_age in game_state['food_age']]
     width, height = game_state['shape']
     walls = game_state['walls']
-    rnd = game_state['rnd']
+    rng = game_state['rng']
     if max_food_age is None:
         max_food_age = game_state['max_food_age']
 
@@ -186,7 +185,7 @@ def relocate_expired_food(game_state, team, radius, max_food_age=None):
                 # relocated at the next round
                 continue
             # choose a new position at random
-            new_pos = rnd.choice(targets)
+            new_pos = rng.choice(targets)
 
             # remove the new pellet position from the list of possible targets for new pellets
             targets.remove(new_pos)
diff --git a/pelita/layout.py b/pelita/layout.py
index 6307806de..2e6e2750c 100644
--- a/pelita/layout.py
+++ b/pelita/layout.py
@@ -1,16 +1,16 @@
-import importlib.resources as importlib_resources
+
 import io
 import os
-import random
+import importlib.resources as importlib_resources
+
+from .base_utils import default_rng
 
 # bot to index conversion
 BOT_N2I = {'a': 0, 'b': 2, 'x': 1, 'y': 3}
 BOT_I2N = {0: 'a', 2: 'b', 1: 'x', 3: 'y'}
 
 
-RNG = random.Random()
-
-def get_random_layout(size='normal', seed=None, dead_ends=0):
+def get_random_layout(size='normal', dead_ends=0, rng=None):
     """ Return a random layout string from the available ones.
 
     Parameters
@@ -34,13 +34,15 @@ def get_random_layout(size='normal', seed=None, dead_ends=0):
         the name of the layout, a random layout string
 
     """
-    if seed is not None:
-        RNG.seed(seed)
-    if dead_ends and RNG.random() < dead_ends:
+
+    # set the random state
+    rng = default_rng(rng)
+
+    if dead_ends and rng.random() < dead_ends:
         layouts_names = get_available_layouts(size=size, dead_ends=True)
     else:
         layouts_names = get_available_layouts(size=size, dead_ends=False)
-    layout_choice = RNG.choice(layouts_names)
+    layout_choice = rng.choice(layouts_names)
     return layout_choice, get_layout_by_name(layout_choice)
 
 def get_available_layouts(size='normal', dead_ends=False):
diff --git a/pelita/maze_generator.py b/pelita/maze_generator.py
index 7e3d7c8ea..67a68a88b 100644
--- a/pelita/maze_generator.py
+++ b/pelita/maze_generator.py
@@ -19,11 +19,12 @@
 Completely rewritten by Pietro Berkes
 Rewritten again (but not completely) by Tiziano Zito
 """
-import random
 
 import numpy as np
 import networkx as nx
 
+from .base_utils import default_rng
+
 
 north = (0, -1)
 south = (0, 1)
@@ -91,12 +92,13 @@ def str_to_maze(str_):
     bytes_maze = str_.encode('ascii')
     return bytes_to_maze(bytes_maze)
 
-def create_half_maze(maze, ngaps_center):
+def create_half_maze(maze, ngaps_center, rng=None):
     """Fill the left half of the maze with random walls.
 
     The second half can be created by mirroring the left part using
     the 'complete_maze' function.
     """
+    rng = default_rng(rng)
 
     # first, we need a wall in the middle
 
@@ -104,7 +106,7 @@ def create_half_maze(maze, ngaps_center):
     # be mirrored
     ch = maze.shape[0] - 2
     candidates = list(range(ch//2))
-    random.shuffle(candidates)
+    rng.shuffle(candidates)
     half_gaps_pos = candidates[:ngaps_center // 2]
     gaps_pos = []
     for pos in half_gaps_pos:
@@ -113,12 +115,12 @@ def create_half_maze(maze, ngaps_center):
 
     # make wall
     _add_wall_at(maze, (maze.shape[1] - 2) // 2 - 1, ngaps_center,
-                 vertical=True, gaps_pos=gaps_pos)
+                 vertical=True, rng=rng, gaps_pos=gaps_pos)
 
     # then, fill the left half with walls
-    _add_wall(maze[:, :maze.shape[1] // 2], ngaps_center // 2, vertical=False)
+    _add_wall(maze[:, :maze.shape[1] // 2], ngaps_center // 2, vertical=False, rng=rng)
 
-def _add_wall_at(maze, pos, ngaps, vertical, gaps_pos=None):
+def _add_wall_at(maze, pos, ngaps, vertical, rng, gaps_pos=None):
     """
     add a wall with gaps
 
@@ -142,7 +144,7 @@ def _add_wall_at(maze, pos, ngaps, vertical, gaps_pos=None):
     if gaps_pos is None:
         # choose aandom positions
         gaps_pos = list(range(ch))
-        random.shuffle(gaps_pos)
+        rng.shuffle(gaps_pos)
         gaps_pos = gaps_pos[:ngaps]
         # do not block entrances
         if maze[0][pos + 1] == E:
@@ -159,7 +161,7 @@ def _add_wall_at(maze, pos, ngaps, vertical, gaps_pos=None):
 
     return sub_mazes
 
-def _add_wall(maze, ngaps, vertical):
+def _add_wall(maze, ngaps, vertical, rng):
     """Recursively build the walls of the maze.
 
     grid -- 2D array of characters representing the maze
@@ -177,15 +179,15 @@ def _add_wall(maze, ngaps, vertical):
 
     size = cw if vertical else ch
     # create a wall only if there is some space in this direction
-    min_size = random.randint(3, 5)
+    min_size = rng.randint(3, 5)
     if size >= min_size:
         # place the wall at random spot
-        pos = random.randint(1, size-2)
-        sub_mazes = _add_wall_at(maze, pos, ngaps, vertical)
+        pos = rng.randint(1, size-2)
+        sub_mazes = _add_wall_at(maze, pos, ngaps, vertical, rng=rng)
 
         # recursively add walls
         for sub_maze in sub_mazes:
-            _add_wall(sub_maze, max(1, ngaps // 2), not vertical)
+            _add_wall(sub_maze, max(1, ngaps // 2), not vertical, rng=rng)
 
 
 def walls_to_graph(maze):
@@ -318,7 +320,9 @@ def get_neighboring_walls(maze, locs):
                 walls.append((adjx, adjy))
     return walls
 
-def remove_all_chambers(maze):
+def remove_all_chambers(maze, rng=None):
+    rng = default_rng(rng)
+
     maze_graph = walls_to_graph(maze)
     # this will find one of the chambers, if there is any
     entrance, chamber = find_chamber(maze_graph)
@@ -326,7 +330,7 @@ def remove_all_chambers(maze):
         # get all the walls around the chamber
         walls = get_neighboring_walls(maze, chamber)
         # choose a wall at random among the neighboring one and get rid of it
-        bad_wall = random.choice(walls)
+        bad_wall = rng.choice(walls)
         maze[bad_wall[1], bad_wall[0]] = E
         # we may have opened a door into this chamber, but there may be more
         # chambers to get rid of. Or, the wall we picked wasn't good enough and
@@ -339,11 +343,13 @@ def remove_all_chambers(maze):
         entrance, chamber = find_chamber(maze_graph)
 
 
-def add_food(maze, max_food):
+def add_food(maze, max_food, rng=None):
     """Add max_food pellets on the left side of the maze.
 
     We exclude the pacmen's starting positions and the central dividing border
     """
+    rng = default_rng(rng)
+
     if max_food == 0:
         # no food needs to be added, return here
         return
@@ -366,7 +372,7 @@ def add_food(maze, max_food):
         raise ValueError(f'Can not add negative number of food ({max_food} given)')
 
     # now take max_food random positions out of this list
-    food = random.sample(free, max_food)
+    food = rng.sample(free, max_food)
     # fit it in the maze
     for col, row in food:
         maze[row, col] = F
@@ -378,7 +384,7 @@ def add_pacmen(maze):
     maze[1, -2] = b'y'
     maze[2, -2] = b'x'
 
-def get_new_maze(height, width, nfood, seed=None, dead_ends=False):
+def get_new_maze(height, width, nfood, dead_ends=False, rng=None):
     """Create a new maze in text format.
 
     The maze is created with a recursive creation algorithm. The maze part of
@@ -401,12 +407,10 @@ def get_new_maze(height, width, nfood, seed=None, dead_ends=False):
     if width%2 != 0:
         raise ValueError(f'Width must be even ({width} given)')
 
-    if seed is None:
-        seed = random.randint(1, 2 ** 31 - 1)
-    random.seed(seed)
+    rng = default_rng(rng)
 
     maze = empty_maze(height, width)
-    create_half_maze(maze, height // 2)
+    create_half_maze(maze, height // 2, rng=rng)
 
     # make space for pacman (2 pacman each)
     maze[-2, 1] = E
@@ -415,10 +419,10 @@ def get_new_maze(height, width, nfood, seed=None, dead_ends=False):
     # remove dead ends
     if not dead_ends:
         remove_all_dead_ends(maze)
-        remove_all_chambers(maze)
+        remove_all_chambers(maze, rng=rng)
 
     # add food
-    add_food(maze, nfood)
+    add_food(maze, nfood, rng=rng)
 
     # complete right part of maze with mirror copy
     maze[:, width // 2:] = np.flipud(np.fliplr(maze[:, :width // 2]))
diff --git a/pelita/scripts/pelita_main.py b/pelita/scripts/pelita_main.py
index 821f2f392..190dd0b73 100755
--- a/pelita/scripts/pelita_main.py
+++ b/pelita/scripts/pelita_main.py
@@ -3,9 +3,9 @@
 import argparse
 import json
 import logging
-from pathlib import Path
 import random
 import sys
+from pathlib import Path
 from urllib.parse import urlparse
 
 from rich.console import Console
@@ -432,7 +432,7 @@ def main():
     else:
         seed = args.seed
 
-    random.seed(seed)
+    rng = random.Random(seed)
 
     if args.layout:
         # first check if the given layout is a file
@@ -449,12 +449,12 @@ def main():
             layout_name = args.layout
             layout_string = pelita.layout.get_layout_by_name(args.layout)
     else:
-        layout_name, layout_string = pelita.layout.get_random_layout(args.size, seed=seed, dead_ends=DEAD_ENDS)
+        layout_name, layout_string = pelita.layout.get_random_layout(args.size, rng=rng, dead_ends=DEAD_ENDS)
 
     print("Using layout '%s'" % layout_name)
 
     layout_dict = pelita.layout.parse_layout(layout_string)
-    pelita.game.run_game(team_specs=team_specs, max_rounds=args.rounds, layout_dict=layout_dict, layout_name=layout_name, seed=seed,
+    pelita.game.run_game(team_specs=team_specs, max_rounds=args.rounds, layout_dict=layout_dict, layout_name=layout_name, rng=rng,
                          allow_camping=args.allow_camping, timeout_length=args.timeout_length, error_limit=args.error_limit,
                          viewers=viewers, viewer_options=viewer_options,
                          store_output=args.store_output,
diff --git a/pelita/scripts/pelita_server.py b/pelita/scripts/pelita_server.py
index 70ead7403..558e9bb4f 100755
--- a/pelita/scripts/pelita_server.py
+++ b/pelita/scripts/pelita_server.py
@@ -3,17 +3,18 @@
 from dataclasses import dataclass
 import json
 import logging
-import random
+
 import queue
 import shlex
 import signal
 import subprocess
 import sys
 import time
-from typing import Optional, Dict, List
 import urllib
-from urllib.parse import urlparse
 import urllib.parse
+from typing import Optional, Dict, List
+from urllib.parse import urlparse
+from random import Random
 from weakref import WeakValueDictionary
 
 import click
@@ -560,7 +561,9 @@ def remote_server(address, port, teams, advertise, max_connections):
     #    - name: def
     #      spec: path/to/module
 
-    session_key = "".join(str(random.randint(0, 9)) for _ in range(12))
+    rng = Random()
+
+    session_key = "".join(str(rng.randint(0, 9)) for _ in range(12))
     pprint(f"Use --session-key {session_key} to for the admin API.")
 
     team_infos = []
diff --git a/pelita/scripts/pelita_tournament.py b/pelita/scripts/pelita_tournament.py
index ff17aaaa9..ef0f4e0d7 100755
--- a/pelita/scripts/pelita_tournament.py
+++ b/pelita/scripts/pelita_tournament.py
@@ -3,11 +3,11 @@
 import argparse
 import datetime
 import itertools
-from pathlib import Path
-import random
 import re
 import shlex
 import sys
+from pathlib import Path
+from random import Random
 
 import shutil
 import yaml
@@ -266,6 +266,8 @@ def escape(s):
     if args.rounds:
         config.rounds = args.rounds
 
+    rng = Random(config.seed)
+
     if Path(args.state).is_file():
         if not args.load_state:
             config.print(f"Found state file in {args.state}. Restore with --load-state. Aborting.")
@@ -273,9 +275,7 @@ def escape(s):
         else:
             state = tournament.State.load(config, args.state)
     else:
-        state = tournament.State(config)
-
-    random.seed(config.seed)
+        state = tournament.State(config, rng=rng)
 
     if state.round1['played']:
         # We have already played one match. Do not speak the introduction.
@@ -286,11 +286,11 @@ def escape(s):
     else:
         tournament.present_teams(config)
 
-    rr_ranking = tournament.play_round1(config, state)
+    rr_ranking = tournament.play_round1(config, state, rng)
     state.round2["round_robin_ranking"] = rr_ranking
     state.save(args.state)
 
-    winner = tournament.play_round2(config, rr_ranking, state)
+    winner = tournament.play_round2(config, rr_ranking, state, rng)
 
     config.print('The winner of the %s Pelita tournament is...' % config.location, wait=2, end=" ")
     config.print('{team_group}: {team_name}. Congratulations'.format(
diff --git a/pelita/team.py b/pelita/team.py
index aa171ad1f..a847348f0 100644
--- a/pelita/team.py
+++ b/pelita/team.py
@@ -1,12 +1,12 @@
 
 import logging
 import os
-import random
 import subprocess
 import sys
 import traceback
 from io import StringIO
 from pathlib import Path
+from random import Random
 from urllib.parse import urlparse
 
 import zmq
@@ -110,7 +110,7 @@ def __init__(self, team_move, *, team_name=""):
         self._state = {}
 
         #: The team’s random number generator
-        self._random = None
+        self._rng = None
 
         #: The history of bot positions
         self._bot_track = [[], []]
@@ -138,7 +138,7 @@ def set_initial(self, team_id, game_state):
 
         # Initialize the random number generator
         # with the seed that we received from game
-        self._random = random.Random(game_state['seed'])
+        self._rng = Random(game_state['seed'])
 
         # Reset the bot tracks
         self._bot_track = [[], []]
@@ -185,7 +185,7 @@ def get_move(self, game_state):
                        enemy=game_state['enemy'],
                        round=game_state['round'],
                        bot_turn=game_state['bot_turn'],
-                       rng=self._random,
+                       rng=self._rng,
                        graph=self._graph)
 
         team = me._team
diff --git a/pelita/tournament/__init__.py b/pelita/tournament/__init__.py
index abc279622..21daf6c83 100644
--- a/pelita/tournament/__init__.py
+++ b/pelita/tournament/__init__.py
@@ -4,7 +4,6 @@
 import json
 import logging
 import os
-import random
 import re
 import shlex
 import signal
@@ -387,12 +386,12 @@ def wait_for_keypress(self):
 
 
 class State:
-    def __init__(self, config, state=None):
+    def __init__(self, config, rng, state=None):
         if state is None:
             self.state = {
                 "round1": {
                     "played": [],
-                    "unplayed": roundrobin.create_matchplan(config.team_ids)
+                    "unplayed": roundrobin.create_matchplan(config.team_ids, rng=rng)
                 },
                 "round2": {}
             }
@@ -450,7 +449,7 @@ def set_name(team):
         raise
 
 
-def play_game_with_config(config, teams, *, match_id=None):
+def play_game_with_config(config, teams, rng, *, match_id=None):
     team1, team2 = teams
 
     if config.tournament_log_folder:
@@ -468,7 +467,7 @@ def play_game_with_config(config, teams, *, match_id=None):
         log_folder = None
         log_kwargs = {}
 
-    seed = str(random.randint(0, sys.maxsize))
+    seed = str(rng.randint(0, sys.maxsize))
     team_infos = [config.team_group(team1), config.team_group(team2)]
 
     res = call_pelita([config.team_spec(team1), config.team_spec(team2)],
@@ -487,7 +486,7 @@ def play_game_with_config(config, teams, *, match_id=None):
     return res
 
 
-def start_match(config, teams, *, shuffle=False, match_id=None):
+def start_match(config, teams, rng, *, shuffle=False, match_id=None):
     """Start a match between a list of teams. Return the index of the team that won
     False if there was a draw.
     """
@@ -496,7 +495,7 @@ def start_match(config, teams, *, shuffle=False, match_id=None):
     # assert len(teams) == len(set(teams))
 
     if shuffle:
-        random.shuffle(teams)
+        rng.shuffle(teams)
 
     team1, team2 = teams
 
@@ -505,7 +504,7 @@ def start_match(config, teams, *, shuffle=False, match_id=None):
     config.print()
     config.wait_for_keypress()
 
-    (final_state, stdout, stderr) = play_game_with_config(config, teams, match_id=match_id)
+    (final_state, stdout, stderr) = play_game_with_config(config, teams, rng=rng, match_id=match_id)
     try:
         whowins = final_state['whowins']
 
@@ -530,10 +529,10 @@ def start_match(config, teams, *, shuffle=False, match_id=None):
         return None
 
 
-def start_match_with_replay(config, match, *, shuffle=False, match_id=None):
+def start_match_with_replay(config, match, rng, *, shuffle=False, match_id=None):
     """ Runs start_match until it returns a proper output or manual intervention. """
 
-    winner = start_match(config, match, shuffle=shuffle, match_id=match_id)
+    winner = start_match(config, match, rng=rng, shuffle=shuffle, match_id=match_id)
     while winner is None:
         config.print("Do you want to re-play the game or enter a winner manually?")
         res = config.input("(r)e-play/(0){}/(1){}/(d)raw > ".format(config.team_name(match[0]),
@@ -542,7 +541,7 @@ def start_match_with_replay(config, match, *, shuffle=False, match_id=None):
             if match_id:
                 # increase the repeat count
                 match_id.next_repeat()
-            winner = start_match(config, match, shuffle=shuffle, match_id=match_id)
+            winner = start_match(config, match, rng=rng, shuffle=shuffle, match_id=match_id)
         elif res == '0':
             winner = match[0]
         elif res == '1':
@@ -552,7 +551,7 @@ def start_match_with_replay(config, match, *, shuffle=False, match_id=None):
     return winner
 
 
-def start_deathmatch(config, team1, team2, *, match_id=None):
+def start_deathmatch(config, team1, team2, rng, *, match_id=None):
     """Start a match between team1 and team2 until one of them wins (ie no
     draw.)
     """
@@ -560,7 +559,7 @@ def start_deathmatch(config, team1, team2, *, match_id=None):
     config.print()
     config.print("{} v {}".format(config.team_name(team1), config.team_name(team2)))
     for i in range(3):
-        winner = start_match_with_replay(config, [team1, team2], shuffle=True, match_id=match_id)
+        winner = start_match_with_replay(config, [team1, team2], rng=rng, shuffle=True, match_id=match_id)
         config.wait_for_keypress()
 
         if winner is False or winner is None:
@@ -572,7 +571,7 @@ def start_deathmatch(config, team1, team2, *, match_id=None):
     # if we are here, we have no winner after 3 death matches
     # just assign a random winner
     config.print('No winner after 3 Death Matches. Choose a winner at random:', wait=2)
-    winner = random.choice((team1, team2))
+    winner = rng.choice((team1, team2))
     config.print('And the winner is', config.team_name(winner))
     return winner
 
@@ -612,7 +611,7 @@ def pp_round1_results(config, rr_played, rr_unplayed, highlight=None):
     config.print()
 
 
-def play_round1(config, state):
+def play_round1(config, state, rng):
     """Run the first round and return a sorted list of team names.
 
     teams is the sorted list [group0, group1, ...] and not the actual names of
@@ -648,7 +647,7 @@ def play_round1(config, state):
     while rr_unplayed:
         match = rr_unplayed.pop()
 
-        winner = start_match_with_replay(config, match, match_id=match_id)
+        winner = start_match_with_replay(config, match, rng=rng, match_id=match_id)
         match_id.next_match()
         config.wait_for_keypress()
 
@@ -690,7 +689,7 @@ def recur_match_winner(match):
     return None
 
 
-def play_round2(config, teams, state):
+def play_round2(config, teams, state, rng):
     """Run the second round and return the name of the winning team.
 
     teams is the list [group0, group1, ...] not the names of the agens, sorted
@@ -722,7 +721,7 @@ def play_round2(config, teams, state):
                 t1_id = recur_match_winner(match.t1)
                 t2_id = recur_match_winner(match.t2)
                 if not match.winner:
-                    winner = start_deathmatch(config, t1_id, t2_id, match_id=match_id)
+                    winner = start_deathmatch(config, t1_id, t2_id, rng=rng, match_id=match_id)
                     match.winner = winner
 
                     config.print(knockout_mode.print_knockout(last_match, config.team_name, highlight=[match]), speak=False)
diff --git a/pelita/tournament/roundrobin.py b/pelita/tournament/roundrobin.py
index 4b07925d2..f287ee773 100644
--- a/pelita/tournament/roundrobin.py
+++ b/pelita/tournament/roundrobin.py
@@ -1,9 +1,8 @@
 from collections import Counter
 import itertools
-import random
 from typing import List, Tuple
 
-def create_matchplan(teams: List[str]) -> List[Tuple[str, str]]:
+def create_matchplan(teams: List[str], rng) -> List[Tuple[str, str]]:
     """ Takes a list of team ids and returns a list of tuples of team ids with
     the matchplan."""
 
@@ -30,14 +29,14 @@ def create_matchplan(teams: List[str]) -> List[Tuple[str, str]]:
 
 
     if len(teams) == 3:
-        matchplan_indexes = matchplan_three_teams()
+        matchplan_indexes = matchplan_three_teams(rng)
     elif len(teams) == 5:
-        matchplan_indexes = matchplan_five_teams()
+        matchplan_indexes = matchplan_five_teams(rng)
     else:
-        matchplan_indexes = circle_method(len(teams))
+        matchplan_indexes = circle_method(len(teams), rng)
 
     shuffled_teams = list(teams)
-    random.shuffle(shuffled_teams)
+    rng.shuffle(shuffled_teams)
 
     matchplan = []
     for match in matchplan_indexes:
@@ -48,15 +47,15 @@ def create_matchplan(teams: List[str]) -> List[Tuple[str, str]]:
 
     return matchplan
 
-def matchplan_three_teams():
+def matchplan_three_teams(rng):
     # Without loss of generality, there should be exactly two possible matchplans with evenly distributed blue/red
     matchplans = [
         [(0, 1), (1, 2), (2, 0)],
         [(0, 1), (2, 0), (1, 2)]
     ]
-    return random.choice(matchplans)
+    return rng.choice(matchplans)
 
-def matchplan_five_teams():
+def matchplan_five_teams(rng):
     # Without loss of generality, there are two possible matchplans for five teams (with the starting order undefined)
     # that don’t have the same team play two consecutive matches. (Assuming the teams are assigned randomly.)
     # (Source: pen&paper + brute force analysis)
@@ -64,7 +63,7 @@ def matchplan_five_teams():
     # [{0, 1}, {2, 3}, {0, 4}, {1, 2}, {3, 4}, {0, 2}, {1, 3}, {2, 4}, {0, 3}, {1, 4}]
     # [{0, 1}, {2, 3}, {0, 4}, {1, 2}, {3, 4}, {0, 2}, {1, 4}, {0, 3}, {2, 4}, {1, 3}]
 
-    matchplan = random.choice([
+    matchplan = rng.choice([
         [(0, 1), (2, 3), (0, 4), (1, 2), (3, 4), (0, 2), (1, 3), (2, 4), (0, 3), (1, 4)],
         [(0, 1), (2, 3), (0, 4), (1, 2), (3, 4), (0, 2), (1, 4), (0, 3), (2, 4), (1, 3)]
     ])
@@ -79,12 +78,12 @@ def matchplan_five_teams():
             valid_mp = [(match[idx], match[1 - idx]) for match, idx in zip(matchplan, shuffle)]
             valid_matchplans.append(valid_mp)
 
-    return random.choice(valid_matchplans)
+    return rng.choice(valid_matchplans)
 
-def circle_method(num_teams):
-    return list(circle_method_gen(num_teams))
+def circle_method(num_teams, rng):
+    return list(circle_method_gen(num_teams, rng))
 
-def circle_method_gen(num_teams):
+def circle_method_gen(num_teams, rng):
     """ For the given number of teams, create a matchplan for a round-robin tournament using the circle method. """
 
     FILLER_TEAM = object()
@@ -119,10 +118,10 @@ def circle_method_gen(num_teams):
 
     # corner indexes
     if num_teams % 2 == 0:
-        fixed_index = random.choice([0, len(teams) // 2 - 1, len(teams) // 2, len(teams) - 1])
+        fixed_index = rng.choice([0, len(teams) // 2 - 1, len(teams) // 2, len(teams) - 1])
     else:
         # for an odd number of teams, only select the column with the filler team
-        fixed_index = random.choice([0, len(teams) - 1])
+        fixed_index = rng.choice([0, len(teams) - 1])
 
     flip_fixed = False
     for _iter in range(len(teams) - 1):
diff --git a/pelita/utils.py b/pelita/utils.py
index 0167bf8ab..9b2d394f2 100644
--- a/pelita/utils.py
+++ b/pelita/utils.py
@@ -1,4 +1,4 @@
-import random
+from random import Random
 
 import networkx as nx
 
@@ -13,14 +13,12 @@
 # older clients!
 from .team import walls_to_graph
 
-RNG = random.Random()
 
-
-def _parse_layout_arg(*, layout=None, food=None, bots=None, seed=None):
+def _parse_layout_arg(*, layout=None, food=None, bots=None, rng=None):
 
     # prepare layout argument to be passed to pelita.game.run_game
     if layout is None:
-        layout_name, layout_str = get_random_layout(size='normal', seed=seed, dead_ends=DEAD_ENDS)
+        layout_name, layout_str = get_random_layout(size='normal', rng=rng, dead_ends=DEAD_ENDS)
         layout_dict = parse_layout(layout_str)
     elif layout in get_available_layouts(size='all'):
         # check if this is a built-in layout
@@ -118,13 +116,15 @@ def run_background_game(*, blue_move, red_move, layout=None, max_rounds=300, see
 
     # if the seed is not set explicitly, set it here
     if seed is None:
-        seed = RNG.randint(1, 2**31)
-        RNG.seed(seed)
+        rng = Random()
+        seed = rng.randint(1, 2**31)
+    else:
+        rng = Random(seed)
 
-    layout_dict, layout_name = _parse_layout_arg(layout=layout, seed=seed)
+    layout_dict, layout_name = _parse_layout_arg(layout=layout, rng=rng)
 
     game_state = run_game((blue_move, red_move), layout_dict=layout_dict,
-                          layout_name=layout_name, max_rounds=max_rounds, seed=seed,
+                          layout_name=layout_name, max_rounds=max_rounds, rng=rng,
                           team_names=('blue', 'red'), allow_exceptions=True, print_result=False)
     out = {}
     out['seed'] = seed
@@ -237,7 +237,7 @@ def setup_test_game(*, layout, is_blue=True, round=None, score=None, seed=None,
         enemy_positions = [layout['bots'][0], layout['bots'][2]]
         is_noisy_enemy = [is_noisy["a"], is_noisy["b"]]
 
-    rng = random.Random(seed)
+    rng = Random(seed)
 
     team = {
         'bot_positions': bot_positions,


Test patch:
diff --git a/test/test_filter_gamestates.py b/test/test_filter_gamestates.py
index fe1b6cd47..804d9fa56 100644
--- a/test/test_filter_gamestates.py
+++ b/test/test_filter_gamestates.py
@@ -2,7 +2,7 @@
 import pytest
 
 import collections
-import random
+from random import Random
 
 from pelita import gamestate_filters as gf
 from pelita.game import setup_game, play_turn, prepare_bot_state, split_food
@@ -11,6 +11,8 @@
 import pelita.utils
 import pelita.game
 
+RNG = Random()
+
 def make_gamestate():
     def dummy_team(bot, state):
         return bot.position, state
@@ -92,7 +94,7 @@ def test_noiser_no_negative_coordinates(bot_id):
     enemy_group = 1 - (bot_id // 2)
     enemy_positions = gamestate['bots'][enemy_group::2]
     noised = gf.noiser(walls, shape=shape, bot_position=bot_position, enemy_positions=enemy_positions,
-                       noise_radius=5, sight_distance=5, rnd=None)
+                       noise_radius=5, sight_distance=5, rng=None)
     new_bots = noised["enemy_positions"]
     test_1 = all(item[0] > 0 for item in new_bots)
     test_2 = all(item[1] > 0 for item in new_bots)
@@ -287,7 +289,7 @@ def test_noiser_noising_at_noise_radius_extreme(ii):
     still noise within confines of maze, despite extreme radius """
 
     gamestate = make_gamestate()
-    gamestate["turn"] = random.randint(0, 3)
+    gamestate["turn"] = RNG.randint(0, 3)
     team_id = gamestate["turn"] % 2
     old_bots = gamestate["bots"]
     team_bots = old_bots[team_id::2]
@@ -296,7 +298,7 @@ def test_noiser_noising_at_noise_radius_extreme(ii):
                        shape=gamestate["shape"],
                        bot_position=gamestate["bots"][gamestate["turn"]],
                        enemy_positions=enemy_bots,
-                       noise_radius=50, sight_distance=5, rnd=None)
+                       noise_radius=50, sight_distance=5, rng=None)
 
     assert all(noised["is_noisy"])
 
@@ -694,7 +696,7 @@ def test_relocate_expired_food(dummy):
         parsed.update({
             "food": food,
             "food_age": food_age,
-            "rnd" : random.Random(),
+            "rng" : Random(),
         })
 
         radius = 2
@@ -747,7 +749,7 @@ def test_relocate_expired_food_nospaceleft():
     parsed.update({
         "food": food,
         "food_age": food_age,
-        "rnd" : random.Random(),
+        "rng" : Random(),
     })
 
     radius = 2
diff --git a/test/test_game.py b/test/test_game.py
index 7f203bfdc..036b82dc0 100644
--- a/test/test_game.py
+++ b/test/test_game.py
@@ -1,14 +1,14 @@
 """Tests for Pelita game module"""
 import pytest
 
-from contextlib import contextmanager
+
 import inspect
 import itertools
 import os
-from pathlib import Path
-import random
-from textwrap import dedent
 import sys
+from contextlib import contextmanager
+from pathlib import Path
+from random import Random
 
 from pelita import game, layout
 from pelita.exceptions import NoFoodWarning
@@ -803,7 +803,7 @@ def test_play_turn_move():
         "bot_was_killed": [False]*4,
         "errors": [[], []],
         "fatal_errors": [{}, {}],
-        "rnd": random.Random()
+        "rng": Random()
         }
     legal_positions = get_legal_positions(game_state["walls"], game_state["shape"], game_state["bots"][turn])
     game_state_new = apply_move(game_state, legal_positions[0])
diff --git a/test/test_layout.py b/test/test_layout.py
index d17cdd6aa..6b578c7a7 100644
--- a/test/test_layout.py
+++ b/test/test_layout.py
@@ -2,8 +2,7 @@
 
 import itertools
 import math
-import random
-from pathlib import Path
+from random import Random
 from textwrap import dedent
 
 from pelita.layout import *
@@ -67,22 +66,21 @@ def test_get_random_layout_returns_correct_layout():
     layout2 = get_layout_by_name(name)
     assert layout == layout2
 
-def test_get_random_layout_random_seed():
-    name, layout = get_random_layout(size='small', seed=1)
+def test_get_random_layout_random_rng():
+    rng = Random(1)
+    name, layout = get_random_layout(size='small', rng=rng)
     assert name == 'small_017'
 
 def test_get_random_layout_proportion_dead_ends():
     N = 1000
     prop = 0.25
     expected = int(prop*N)
-    # get a fix sequence of seeds, so that the test is reproducible
-    RNG = random.Random()
-    RNG.seed(176399)
-    seeds = [RNG.random() for i in range(N)]
+    # get a fixed rng, so that the test is reproducible
+    rng = Random(176399)
     # check that we don't get any layout with dead ends if we don't ask for it
-    assert not any('dead_ends' in get_random_layout(seed=s)[0] for s in seeds)
+    assert not any('dead_ends' in get_random_layout(rng=rng)[0] for _ in range(N))
     # check that we get more or less the right proportion of layouts with dead ends
-    dead_ends = sum('dead_ends' in get_random_layout(seed=s, dead_ends=prop)[0] for s in seeds)
+    dead_ends = sum('dead_ends' in get_random_layout(rng=rng, dead_ends=prop)[0] for _ in range(N))
     assert math.isclose(dead_ends, expected, rel_tol=0.1)
 
 
diff --git a/test/test_maze_generation.py b/test/test_maze_generation.py
index 2acab7c8d..0f5937a41 100644
--- a/test/test_maze_generation.py
+++ b/test/test_maze_generation.py
@@ -1,5 +1,5 @@
-import random
 import textwrap
+from random import Random
 
 import networkx as nx
 import numpy as np
@@ -10,10 +10,6 @@
 SEED = 103525239
 
 
-@pytest.fixture()
-def set_seed():
-    random.seed(SEED)
-
 def test_maze_bytes_str_conversions():
     # note that the first empty line is needed!
     maze_str = """
@@ -46,7 +42,7 @@ def test_maze_bytes_str_conversions():
     assert mg.maze_to_str(maze_arr) == maze_str
 
 
-def test_create_half_maze(set_seed):
+def test_create_half_maze():
     # this test is not really testing that create_half_maze does a good job
     # we only test that we keep in returning the same maze when the random
     # seed is fixed, in case something changes during future porting/refactoring
@@ -68,8 +64,9 @@ def test_create_half_maze(set_seed):
                   ################################"""
 
     maze = mg.empty_maze(16,32)
-    mg.create_half_maze(maze, 8)
+    mg.create_half_maze(maze, 8, rng=SEED)
     expected = mg.str_to_maze(maze_str)
+    print(mg.maze_to_str(maze))
     assert np.all(maze == expected)
 
 def test_conversion_to_nx_graph():
@@ -108,7 +105,7 @@ def test_find_one_dead_end():
     assert len(dead_ends) == 1
     assert dead_ends[0] == (1,1)
 
-def test_find_multiple_dead_ends(set_seed):
+def test_find_multiple_dead_ends():
     # this maze has exactly three dead ends at coordinates (1,1), (1,5), (3,5)
     maze_dead = """############
                    # #        #
@@ -128,7 +125,7 @@ def test_find_multiple_dead_ends(set_seed):
     assert dead_ends[1] == (1,5)
     assert dead_ends[2] == (3,5)
 
-def test_find_multiple_dead_ends_on_the_right(set_seed):
+def test_find_multiple_dead_ends_on_the_right():
     # this maze has exactly three dead ends at coordinates (10,1), (10,5), (8,5)
     maze_dead = """############
                    #        # #
@@ -160,7 +157,7 @@ def test_remove_one_dead_end():
     mg.remove_dead_end((1,1), maze)
     assert maze[1,1] == mg.E
 
-def test_remove_multiple_dead_ends(set_seed):
+def test_remove_multiple_dead_ends():
     # this maze has exactly three dead ends at coordinates (1,1), (1,5), (3,5)
     maze_dead = """############
                    # #        #
@@ -285,7 +282,7 @@ def test_find_chamber():
                                           maze_neighbor_chambers,
                                           maze_chamber_in_chamber,
                                           maze_chamber_bonanza,))
-def test_remove_all_chambers(set_seed, maze_chamber):
+def test_remove_all_chambers(maze_chamber):
     maze = mg.str_to_maze(maze_chamber)
     mg.remove_all_chambers(maze)
     # there are no more chambers if the connectivity of the graph is larger than 1
@@ -296,10 +293,10 @@ def test_remove_all_chambers(set_seed, maze_chamber):
     # we are removing just a few walls?
 
 @pytest.mark.parametrize('iteration', range(1,11))
-def test_get_new_maze(set_seed, iteration):
+def test_get_new_maze(iteration):
     # generate a few mazes and check them for consistency
-    local_seed = random.randint(1,2**31-1)*iteration
-    maze_str = mg.get_new_maze(8,16,nfood=15,seed=local_seed)
+    local_seed = 12345 * iteration
+    maze_str = mg.get_new_maze(8,16,nfood=15,rng=local_seed)
     maze = mg.str_to_maze(maze_str)
     height, width = maze.shape
     # check that the returned maze has all the pacmen
diff --git a/test/test_player_base.py b/test/test_player_base.py
index 7af9899ef..66f6780cc 100644
--- a/test/test_player_base.py
+++ b/test/test_player_base.py
@@ -1,8 +1,5 @@
-import pytest
-import unittest
 
-import random
-import time
+from random import Random
 
 from pelita.game import setup_game, run_game, play_turn
 from pelita.layout import parse_layout
@@ -122,21 +119,21 @@ def test_demo_players(self):
             random_player,
             random_player
         ]
-        state = setup_game(teams, layout_dict=parse_layout(test_layout), max_rounds=20, seed=20)
+        state = setup_game(teams, layout_dict=parse_layout(test_layout), max_rounds=20, rng=20)
         assert state['bots'][0] == (4, 4)
         assert state['bots'][1] == (4 + 7, 4)
 
-        state = run_game(teams, layout_dict=parse_layout(test_layout), max_rounds=20, seed=20)
+        state = run_game(teams, layout_dict=parse_layout(test_layout), max_rounds=20, rng=20)
         pos_left_bot = state['bots'][0]
         pos_right_bot = state['bots'][1]
 
         # running again to test seed:
-        state = run_game(teams, layout_dict=parse_layout(test_layout), max_rounds=20, seed=20)
+        state = run_game(teams, layout_dict=parse_layout(test_layout), max_rounds=20, rng=20)
         assert state['bots'][0] == pos_left_bot
         assert state['bots'][1] == pos_right_bot
 
         # running again with other seed:
-        state = run_game(teams, layout_dict=parse_layout(test_layout), max_rounds=20, seed=200)
+        state = run_game(teams, layout_dict=parse_layout(test_layout), max_rounds=20, rng=200)
         # most probably, either the left bot or the right bot or both are at
         # a different position
         assert not (state['bots'][0] == pos_left_bot and state['bots'][1] == pos_right_bot)
@@ -165,7 +162,7 @@ def rng_test(bot, state):
             return team, player_rngs
 
         team0, player_rngs0 = init_rng_players()
-        state = setup_game(team0, layout_dict=parse_layout(test_layout), max_rounds=5, seed=20)
+        state = setup_game(team0, layout_dict=parse_layout(test_layout), max_rounds=5, rng=20)
         # play two steps
         play_turn(play_turn(state))
         assert len(player_rngs0) == 2
@@ -175,7 +172,7 @@ def rng_test(bot, state):
         assert random_numbers0[0] != random_numbers0[1]
 
         team1, player_rngs1 = init_rng_players()
-        state = setup_game(team1, layout_dict=parse_layout(test_layout), max_rounds=5, seed=20)
+        state = setup_game(team1, layout_dict=parse_layout(test_layout), max_rounds=5, rng=20)
         # play two steps
         play_turn(play_turn(state))
         assert len(player_rngs1) == 2
@@ -186,7 +183,7 @@ def rng_test(bot, state):
 
         # now, use a different seed
         team2, player_rngs2 = init_rng_players()
-        state = setup_game(team2, layout_dict=parse_layout(test_layout), max_rounds=5, seed=200)
+        state = setup_game(team2, layout_dict=parse_layout(test_layout), max_rounds=5, rng=200)
         # play two steps
         play_turn(play_turn(state))
         assert len(player_rngs2) == 2
diff --git a/test/test_players.py b/test/test_players.py
index f699aee3b..92cb08bee 100644
--- a/test/test_players.py
+++ b/test/test_players.py
@@ -1,4 +1,5 @@
-from random import random
+from random import Random
+
 import pytest
 
 from pelita.game import run_game
diff --git a/test/test_remote_game.py b/test/test_remote_game.py
index d1e0bc2c9..669f4d07e 100644
--- a/test/test_remote_game.py
+++ b/test/test_remote_game.py
@@ -1,9 +1,10 @@
 import pytest
 
-from pathlib import Path
-import random
 import sys
 import tempfile
+from pathlib import Path
+from random import Random
+
 
 import pelita.game
 from pelita.tournament import call_pelita, run_and_terminate_process
@@ -18,7 +19,7 @@
 # The processes should automatically terminate then
 # also, listens on different ports each time, to avoid collisions and dead
 # locks
-RNG = random.Random()
+RNG = Random()
 
 @pytest.fixture(scope="module")
 def remote_teams():
diff --git a/test/test_tournament.py b/test/test_tournament.py
index 9cbdbc11e..18708a1b3 100644
--- a/test/test_tournament.py
+++ b/test/test_tournament.py
@@ -2,12 +2,14 @@
 from unittest.mock import MagicMock
 
 import re
+from random import Random
 from textwrap import dedent
 
 from pelita import tournament
 from pelita.tournament import knockout_mode, roundrobin
 from pelita.tournament.knockout_mode import Team, Match, Bye
 
+RNG = Random()
 
 def test_match_id():
     assert str(tournament.MatchID()) == 'round1-match01'
@@ -158,7 +160,7 @@ def tuple_sort(t):
             return tuple(sorted(list(t)))
 
         for input, output in data:
-            matchplan = roundrobin.create_matchplan(input)
+            matchplan = roundrobin.create_matchplan(input, rng=RNG)
             matchplan_sorted = sorted([tuple_sort(x) for x in matchplan])
             output = sorted([tuple_sort(x) for x in output])
             assert matchplan_sorted == output
@@ -170,7 +172,7 @@ def tuple_sort(t):
             [0, 1, 2, 3, 4, 5, 6, 7]
         ])
     def test_no_team_plays_two_games_in_a_row(self, teams):
-        matches = roundrobin.create_matchplan(teams)
+        matches = roundrobin.create_matchplan(teams, rng=RNG)
         prev = {}
         for match in matches:
             current = set(match)
@@ -188,7 +190,7 @@ def test_no_team_plays_two_games_in_a_row(self, teams):
         ])
     def test_team_blue_red_are_similar(self, teams):
         # test that each team plays a similar amount as blue and red
-        matchplan = roundrobin.create_matchplan(teams)
+        matchplan = roundrobin.create_matchplan(teams, rng=RNG)
         from collections import Counter
         blue_count = Counter()
         red_count = Counter()
@@ -227,14 +229,14 @@ def test_play_game_with_config(self):
         config.tournament_log_folder = None
 
         teams = ["pelita/player/StoppingPlayer", "pelita/player/StoppingPlayer"]
-        (state, stdout, stderr) = tournament.play_game_with_config(config, teams)
+        (state, stdout, stderr) = tournament.play_game_with_config(config, teams, rng=RNG)
         assert state['whowins'] == 2
 
         config.rounds = 200
         config.team_spec = lambda x: x
         config.viewer = 'ascii'
         teams = ["pelita/player/SmartEatingPlayer", "pelita/player/StoppingPlayer"]
-        (state, stdout, stderr) = tournament.play_game_with_config(config, teams)
+        (state, stdout, stderr) = tournament.play_game_with_config(config, teams, rng=RNG)
         print(state)
         assert state['whowins'] == 0
 
@@ -242,7 +244,7 @@ def test_play_game_with_config(self):
         config.team_spec = lambda x: x
         config.viewer = 'ascii'
         teams = ["pelita/player/StoppingPlayer", "pelita/player/SmartEatingPlayer"]
-        (state, stdout, stderr) = tournament.play_game_with_config(config, teams)
+        (state, stdout, stderr) = tournament.play_game_with_config(config, teams, rng=RNG)
         assert state['whowins'] == 1
 
     def test_start_match(self):
@@ -268,17 +270,17 @@ def mock_print(str="", *args, **kwargs):
         config.tournament_log_folder = None
 
         team_ids = ["first_id", "first_id"]
-        result = tournament.start_match(config, team_ids)
+        result = tournament.start_match(config, team_ids, rng=RNG)
         assert result == False
         assert stdout[-1] == '‘pelita/player/StoppingPlayer’ and ‘pelita/player/StoppingPlayer’ had a draw.'
 
         team_ids = ["second_id", "first_id"]
-        result = tournament.start_match(config, team_ids)
+        result = tournament.start_match(config, team_ids, rng=RNG)
         assert result == "second_id"
         assert stdout[-1] == '‘pelita/player/SmartEatingPlayer’ wins'
 
         team_ids = ["first_id", "second_id"]
-        result = tournament.start_match(config, team_ids)
+        result = tournament.start_match(config, team_ids, rng=RNG)
         assert result == "second_id"
         assert stdout[-1] == '‘pelita/player/SmartEatingPlayer’ wins'
 
@@ -305,7 +307,7 @@ def mock_print(str="", *args, **kwargs):
         config.print = mock_print
         config.tournament_log_folder = None
 
-        result = tournament.start_deathmatch(config, *teams.keys())
+        result = tournament.start_deathmatch(config, *teams.keys(), rng=RNG)
         assert result is not None
         assert result in ["first_id", "second_id"]
 
@@ -347,20 +349,20 @@ def mock_print(str="", *args, **kwargs):
         config.tournament_log_folder = None
 
         # group1 should win
-        assert "group1" == tournament.start_match(config, ["group0", "group1"])
-        assert "group1" == tournament.start_match(config, ["group1", "group0"])
-        assert False == tournament.start_match(config, ["group0", "group0"])
+        assert "group1" == tournament.start_match(config, ["group0", "group1"], rng=RNG)
+        assert "group1" == tournament.start_match(config, ["group1", "group0"], rng=RNG)
+        assert False == tournament.start_match(config, ["group0", "group0"], rng=RNG)
 
         tournament.present_teams(config)
 
-        state = tournament.State(config)
-        rr_ranking = tournament.play_round1(config, state)
+        state = tournament.State(config, rng=RNG)
+        rr_ranking = tournament.play_round1(config, state, rng=RNG)
 
         if config.bonusmatch:
             sorted_ranking = knockout_mode.sort_ranks(rr_ranking[:-1]) + [rr_ranking[-1]]
         else:
             sorted_ranking = knockout_mode.sort_ranks(rr_ranking)
 
-        winner = tournament.play_round2(config, sorted_ranking, state)
+        winner = tournament.play_round2(config, sorted_ranking, state, rng=RNG)
         assert winner == 'group1'
 
diff --git a/test/test_utils.py b/test/test_utils.py
index 86901d2b0..9d8e468d6 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -1,9 +1,41 @@
 import pytest
 
+from pelita import base_utils
 from pelita import utils
 from pelita.player import stopping_player
 
 
+def test_default_rng():
+    # test that we get the same rng when using the same seed
+    rng1 = base_utils.default_rng(100)
+    rng2 = base_utils.default_rng(100)
+    rng1_0, rng2_0 = rng1.random(), rng2.random()
+    rng1_1, rng2_1= rng1.random(), rng2.random()
+    assert rng1_0 == rng2_0
+    assert rng1_1 == rng2_1
+    assert rng1_0 != rng1_1
+    assert rng2_0 != rng2_1
+
+def test_default_rng_init_self():
+    # test that we can initialise the rng with itself
+    rng1 = base_utils.default_rng(200)
+    rng2 = base_utils.default_rng(rng1)
+    assert rng1 is rng2
+
+def test_default_rng_init_none():
+    # NB: Test relies on randomness. It should be EXTREMELY unlikely that this test fails
+
+    # test that we can initialise the rng with None
+    rng1 = base_utils.default_rng(None)
+    rng2 = base_utils.default_rng(None)
+    assert rng1 is not rng2
+    rng1_0, rng2_0 = rng1.random(), rng2.random()
+    rng1_1, rng2_1= rng1.random(), rng2.random()
+    assert rng1_0 != rng2_0
+    assert rng1_1 != rng2_1
+    assert rng1_0 != rng1_1
+    assert rng2_0 != rng2_1
+
 @pytest.mark.parametrize('is_blue', [True, False])
 def test_setup_test_game(is_blue):
     # Test that is_noisy is set properly