diff --git a/dev-tools/HARNESS.md b/dev-tools/HARNESS.md
new file mode 100644
index 0000000000000..4be700445f036
--- /dev/null
+++ b/dev-tools/HARNESS.md
@@ -0,0 +1,53 @@
+# Harness
+
+`harness.js` allows for running multiple random simulations of Pokemon battles
+for testing or benchmarking purposes. Without any arguments, `harness.js` will
+run 100 random battles and report any errors that occurred. The number of
+battles run can be configured through by passing the number as the sole argument
+to `harness.js`, or through the `--num` flag (see below).
+
+## Flags
+
+Using any flag will trigger [minimist](https://github.com/substack/minimist) to
+be installed if it has not been already.
+
+### General
+
+-   **`--num`**: play a specific number of games for a format instead of the
+    default 100.
+-   **`--seed`**: PRNG seed to use (eg. `'1234,5678,9012,3456'`).
+-   **`--output`**: makes the harness display the _output_ logs of each battle
+    it runs.
+-   **`--input`**: dump the battle _input_ logs of each battle it runs.
+-   **`--error`**: dump the battle _input_ logs of each battle which errors.
+
+### Format
+
+By default, the harness will select the format of the next game it runs randomly
+based on its initial `--seed`. Alternatively, it can run games all in the same
+format, cycle through the formats or run all formats.
+
+-   **`--format`**: play the specified format for each of the games it runs.
+    Note that the harness only supports formats where the team can be randomly
+    generated.
+-   **`--cycle`**: cycles through the possible formats, playing one battle in
+    each `--num` battles in total.
+-   **`--all`**: plays every supported format `--num` times before moving on to
+    the next format.
+
+### Concurrency
+
+The harness runs games sequentially by default, but can be configured to run
+games asynchronously.
+
+-   **`--async`**: runs each game concurrently instead of waiting for the
+    previous game to complete before starting the next. Note that since battles
+    run through the harness should not be blocking to begin with (battles
+    naturally wait for players to make their decisions, but the AI's should be
+    making decisions pretty much immediately), this mode is not expected to have
+    large performance benefits over the default sequential mode and may require
+    additional memory (run with `node --max-old-space-size=<SIZE>
+    --stack-size=<SIZE>` if you encounter issues).
+
+**TODO**: Add support for running battles in `--parallel` on muliple cores with
+[`worker_threads`](https://nodejs.org/api/worker_threads.html).
diff --git a/dev-tools/harness.js b/dev-tools/harness.js
new file mode 100644
index 0000000000000..6a9312ee1b8b1
--- /dev/null
+++ b/dev-tools/harness.js
@@ -0,0 +1,242 @@
+/**
+ * Random Simulation harness for testing and benchmarking purposes.
+ *
+ * Refer to `HARNESS.md` for detailed usage instructions.
+ *
+ * Pokemon Showdown - http://pokemonshowdown.com/
+ *
+ * @license MIT
+ */
+
+'use strict';
+
+const child_process = require('child_process');
+const shell = cmd => child_process.execSync(cmd, {stdio: 'inherit', cwd: __dirname});
+
+// Run the build script if we're being called from the command line.
+// NOTE: `require('../build')` is not safe because `replace` is async.
+if (require.main === module) shell('node ../build');
+
+const BattleStreams = require('../.sim-dist/battle-stream');
+const PRNG = require('../.sim-dist/prng').PRNG;
+const RandomPlayerAI = require('../.sim-dist/examples/random-player-ai').RandomPlayerAI;
+
+// 'move' 70% of the time (ie. 'switch' 30%) and ' mega' 60% of the time that its an option.
+const AI_OPTIONS = {move: 0.7, mega: 0.6, createAI: (s, o) => new RandomPlayerAI(s, o)};
+
+const FORMATS = [
+	'gen7randombattle', 'gen7randomdoublesbattle', 'gen7battlefactory', 'gen6randombattle', 'gen6battlefactory',
+	'gen5randombattle', 'gen4randombattle', 'gen3randombattle', 'gen2randombattle', 'gen1randombattle',
+];
+
+class Runner {
+	constructor(options) {
+		this.prng = (options.prng && !Array.isArray(options.prng)) ?
+			options.prng : new PRNG(options.prng);
+		this.p1options = Object.assign({}, AI_OPTIONS, options.p1options);
+		this.p2options = Object.assign({}, AI_OPTIONS, options.p2options);
+
+		this.format = options.format;
+		this.input = !!options.input;
+		this.output = !!options.output;
+		this.error = !!options.error;
+	}
+
+	async run() {
+		const battleStream = new RawBattleStream(this.input);
+		const game = this.runGame(this.format, battleStream);
+		if (!this.error) return game;
+		return game.catch(err => {
+			console.log(`\n${battleStream.rawInputLog.join('\n')}\n`);
+			throw err;
+		});
+	}
+
+	async runGame(format, battleStream) {
+		const streams = BattleStreams.getPlayerStreams(battleStream);
+		const spec = {formatid: format, seed: this.prng.seed};
+		const p1spec = this.getPlayerSpec("Bot 1", this.p1options);
+		const p2spec = this.getPlayerSpec("Bot 2", this.p2options);
+
+		const p1 = this.p1options.createAI(
+			streams.p1, Object.assign({seed: this.newSeed()}, this.p1options));
+		const p2 = this.p2options.createAI(
+			streams.p2, Object.assign({seed: this.newSeed()}, this.p2options));
+		// TODO: Use `await Promise.race([streams.omniscient.read(), p1, p2])` to avoid
+		// leaving these promises dangling once it no longer causes memory leaks (v8#9069).
+		p1.start();
+		p2.start();
+
+		streams.omniscient.write(`>start ${JSON.stringify(spec)}\n` +
+			`>player p1 ${JSON.stringify(p1spec)}\n` +
+			`>player p2 ${JSON.stringify(p2spec)}`);
+
+		let chunk;
+		while ((chunk = await streams.omniscient.read())) {
+			if (this.output) console.log(chunk);
+		}
+	}
+
+	// Same as PRNG#generatedSeed, only deterministic.
+	// NOTE: advances this.prng's seed by 4.
+	newSeed() {
+		return [
+			Math.floor(this.prng.next() * 0x10000),
+			Math.floor(this.prng.next() * 0x10000),
+			Math.floor(this.prng.next() * 0x10000),
+			Math.floor(this.prng.next() * 0x10000),
+		];
+	}
+
+	getPlayerSpec(name, options) {
+		if (options.team) return {name, team: options.team};
+		return {name, seed: this.newSeed()};
+	}
+}
+
+class RawBattleStream extends BattleStreams.BattleStream {
+	constructor(input) {
+		super();
+		this.input = !!input;
+		this.rawInputLog = [];
+	}
+
+	_write(message) {
+		if (this.input) console.log(message);
+		this.rawInputLog.push(message);
+		super._write(message);
+	}
+}
+
+class MultiRunner {
+	constructor(options) {
+		this.options = Object.assign({}, options);
+
+		this.totalGames = options.totalGames;
+
+		this.prng = (options.prng && !Array.isArray(options.prng)) ?
+			options.prng : new PRNG(options.prng);
+		this.options.prng = this.prng;
+
+		this.format = options.format;
+		this.cycle = !!options.cycle;
+		this.all = !!options.all;
+
+		this.async = !!options.async;
+
+		this.formatIndex = 0;
+		this.numGames = 0;
+	}
+
+	async run() {
+		let games = [];
+		let format, lastFormat;
+		let failures = 0;
+		while ((format = this.getNextFormat())) {
+			if (this.all && lastFormat && format !== lastFormat) {
+				if (this.async) await Promise.all(games);
+				games = [];
+			}
+
+			const seed = this.prng.seed;
+			const game = new Runner(Object.assign({format}, this.options)).run().catch(err => {
+				failures++;
+				console.error(
+					`Run \`node dev-tools/harness 1 --format=${format} --seed=${seed.join()}\` ` +
+					`to debug (optionally with \`--output\` and/or \`--input\` for more info):\n`, err);
+			});
+
+			if (!this.async) await game;
+			games.push(game);
+			lastFormat = format;
+		}
+
+		if (this.async) await Promise.all(games);
+		return failures;
+	}
+
+	getNextFormat() {
+		if (this.formatIndex > FORMATS.length) return false;
+
+		if (this.numGames++ < this.totalGames) {
+			if (this.format) {
+				return this.format;
+			} else if (this.all) {
+				return FORMATS[this.formatIndex];
+			} else if (this.cycle) {
+				const format = FORMATS[this.formatIndex];
+				this.formatIndex = (this.formatIndex + 1) % FORMATS.length;
+				return format;
+			} else {
+				return this.prng.sample(FORMATS);
+			}
+		} else if (this.all) {
+			this.numGames = 1;
+			this.formatIndex++;
+			return FORMATS[this.formatIndex];
+		}
+
+		return false;
+	}
+}
+
+// Tracks whether some promises threw errors that weren't caught so we can log
+// and exit with a non-zero status to fail any tests. This "shouldn't happen"
+// because we're "great at propagating promises (TM)", but better safe than sorry.
+const RejectionTracker = {
+	unhandled: [],
+	onUnhandledRejection(reason, promise) {
+		this.unhandled.push({reason, promise});
+	},
+	onRejectionHandled(promise) {
+		this.unhandled.splice(this.unhandled.findIndex(u => u.promise === promise), 1);
+	},
+	onExit(code) {
+		let i = 0;
+		for (const u of this.unhandled) {
+			const error = (u.reason instanceof Error) ? u.reason :
+				new Error(`Promise rejected with value: ${u.reason}`);
+			console.error(error.stack);
+			i++;
+		}
+		process.exit(code + i);
+	},
+	register() {
+		process.on('unhandledRejection', (r, p) => this.onUnhandledRejection(r, p));
+		process.on('rejectionHandled', p => this.onRejectionHandled(p));
+		process.on('exit', c => this.onExit(c));
+	},
+};
+
+module.exports = {Runner, MultiRunner, RejectionTracker};
+
+// Kick off the MultiRunner if we're being called from the command line.
+if (require.main === module) {
+	const options = {totalGames: 100};
+	// If we have more than one arg, or the arg looks like a flag, we need minimist to understand it.
+	if (process.argv.length > 3 || process.argv.length === 3 && process.argv[2].startsWith('-')) {
+		const missing = dep => {
+			try {
+				require.resolve(dep);
+				return false;
+			} catch (err) {
+				if (err.code !== 'MODULE_NOT_FOUND') throw err;
+				return true;
+			}
+		};
+
+		if (missing('minimist')) shell('npm install minimist');
+		const argv = require('minimist')(process.argv.slice(2));
+		Object.assign(options, argv);
+		options.totalGames = Number(argv._[0] || argv.num) || options.totalGames;
+		if (argv.seed) options.prng = argv.seed.split(',').map(s => Number(s));
+	} else if (process.argv.length === 3) {
+		// If we have one arg, treat it as the total number of games to play.
+		options.totalGames = Number(process.argv[2]) || options.totalGames;
+	}
+
+	RejectionTracker.register();
+
+	// Run options.totalGames, exiting with the number of games with errors.
+	(async () => process.exit(await new MultiRunner(options).run()))();
+}
diff --git a/test/dev-tools/harness.js b/test/dev-tools/harness.js
new file mode 100644
index 0000000000000..371813ea94d97
--- /dev/null
+++ b/test/dev-tools/harness.js
@@ -0,0 +1,12 @@
+'use strict';
+
+const assert = require('assert');
+
+const {MultiRunner} = require('./../../dev-tools/harness');
+
+describe('MultiRunner', async () => {
+	it('should run successfully', async () => {
+		const opts = {totalGames: 1, prng: [1, 2, 3, 4]};
+		assert.strictEqual(await (new MultiRunner(opts).run()), 0);
+	});
+});
diff --git a/test/mocha.opts b/test/mocha.opts
index 5a2c19eb71774..18275aa96fd0f 100644
--- a/test/mocha.opts
+++ b/test/mocha.opts
@@ -1,4 +1,4 @@
-test/main.js test/simulator/**/*.js test/application/*.js test/chat-plugins/*.js
+test/main.js test/simulator/**/*.js test/application/*.js test/chat-plugins/*.js test/dev-tools/*.js
 -R dot
 -u bdd
 --exit