diff --git a/packages/cli/package.json b/packages/cli/package.json
index 7d667b42ef..6da0bbd994 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -38,6 +38,7 @@
     "@latticexyz/solecs": "workspace:*",
     "@latticexyz/std-contracts": "workspace:*",
     "@latticexyz/store": "workspace:*",
+    "@latticexyz/utils": "workspace:*",
     "@latticexyz/world": "workspace:*",
     "@typechain/ethers-v5": "^10.2.0",
     "chalk": "^5.0.1",
diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts
index ba044ff721..69177e2863 100644
--- a/packages/cli/src/commands/index.ts
+++ b/packages/cli/src/commands/index.ts
@@ -10,6 +10,7 @@ import deploy from "./deploy";
 import worldgen from "./worldgen";
 import setVersion from "./set-version";
 import test from "./test";
+import trace from "./trace";
 import devContracts from "./dev-contracts";
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each command has different options
@@ -24,5 +25,6 @@ export const commands: CommandModule<any, any>[] = [
   worldgen,
   setVersion,
   test,
+  trace,
   devContracts,
 ];
diff --git a/packages/cli/src/commands/trace.ts b/packages/cli/src/commands/trace.ts
new file mode 100644
index 0000000000..2a95d1b4ba
--- /dev/null
+++ b/packages/cli/src/commands/trace.ts
@@ -0,0 +1,108 @@
+import { existsSync, readFileSync } from "fs";
+import type { CommandModule } from "yargs";
+import { ethers } from "ethers";
+
+import { loadConfig } from "@latticexyz/config/node";
+import { MUDError } from "@latticexyz/common/errors";
+import { cast, getRpcUrl, getSrcDirectory } from "@latticexyz/common/foundry";
+import { TableId } from "@latticexyz/utils";
+import { StoreConfig } from "@latticexyz/store";
+import { resolveWorldConfig, WorldConfig } from "@latticexyz/world";
+import { IBaseWorld } from "@latticexyz/world/types/ethers-contracts/IBaseWorld";
+import IBaseWorldData from "@latticexyz/world/abi/IBaseWorld.sol/IBaseWorld.json" assert { type: "json" };
+import { getChainId, getExistingContracts } from "../utils";
+
+const systemsTableId = new TableId("", "Systems");
+
+type Options = {
+  tx: string;
+  worldAddress?: string;
+  configPath?: string;
+  profile?: string;
+  srcDir?: string;
+  rpc?: string;
+};
+
+const commandModule: CommandModule<Options, Options> = {
+  command: "trace",
+
+  describe: "Display the trace of a transaction",
+
+  builder(yargs) {
+    return yargs.options({
+      tx: { type: "string", required: true, description: "Transaction hash to replay" },
+      worldAddress: {
+        type: "string",
+        description: "World contract address. Defaults to the value from worlds.json, based on rpc's chainId",
+      },
+      configPath: { type: "string", description: "Path to the config file" },
+      profile: { type: "string", description: "The foundry profile to use" },
+      srcDir: { type: "string", description: "Source directory. Defaults to foundry src directory." },
+      rpc: { type: "string", description: "json rpc endpoint. Defaults to foundry's configured eth_rpc_url" },
+    });
+  },
+
+  async handler(args) {
+    args.profile ??= process.env.FOUNDRY_PROFILE;
+    const { profile } = args;
+    args.srcDir ??= await getSrcDirectory(profile);
+    args.rpc ??= await getRpcUrl(profile);
+    const { tx, configPath, srcDir, rpc } = args;
+
+    const existingContracts = getExistingContracts(srcDir);
+
+    // Load the config
+    const mudConfig = (await loadConfig(configPath)) as StoreConfig & WorldConfig;
+
+    const resolvedConfig = resolveWorldConfig(
+      mudConfig,
+      existingContracts.map(({ basename }) => basename)
+    );
+
+    // Get worldAddress either from args or from worldsFile
+    const worldAddress = args.worldAddress ?? (await getWorldAddress(mudConfig.worldsFile, rpc));
+
+    // Create World contract instance from deployed address
+    const provider = new ethers.providers.StaticJsonRpcProvider(rpc);
+    const WorldContract = new ethers.Contract(worldAddress, IBaseWorldData.abi, provider) as IBaseWorld;
+
+    // TODO account for multiple namespaces (https://github.com/latticexyz/mud/issues/994)
+    const namespace = mudConfig.namespace;
+    const names = Object.values(resolvedConfig.systems).map(({ name }) => name);
+
+    const labels: { name: string; address: string }[] = [];
+    for (const name of names) {
+      const systemSelector = new TableId(namespace, name);
+      // Get the first field of `Systems` table (the table maps system name to its address and other data)
+      const address = await WorldContract.getField(systemsTableId.toHexString(), [systemSelector.toHexString()], 0);
+      labels.push({ name, address });
+    }
+
+    const result = await cast([
+      "run",
+      "--label",
+      `${worldAddress}:World`,
+      ...labels.map(({ name, address }) => ["--label", `${address}:${name}`]).flat(),
+      `${tx}`,
+    ]);
+    console.log(result);
+
+    process.exit(0);
+  },
+};
+
+export default commandModule;
+
+async function getWorldAddress(worldsFile: string, rpc: string) {
+  if (existsSync(worldsFile)) {
+    const chainId = await getChainId(rpc);
+    const deploys = JSON.parse(readFileSync(worldsFile, "utf-8"));
+
+    if (!deploys[chainId]) {
+      throw new MUDError(`chainId ${chainId} is missing in worldsFile "${worldsFile}"`);
+    }
+    return deploys[chainId].address as string;
+  } else {
+    throw new MUDError("worldAddress is not specified and worldsFile is missing");
+  }
+}
diff --git a/packages/cli/src/commands/worldgen.ts b/packages/cli/src/commands/worldgen.ts
index 49ac781054..0cee74c92d 100644
--- a/packages/cli/src/commands/worldgen.ts
+++ b/packages/cli/src/commands/worldgen.ts
@@ -4,9 +4,9 @@ import { StoreConfig } from "@latticexyz/store";
 import { WorldConfig } from "@latticexyz/world";
 import { worldgen } from "@latticexyz/world/node";
 import { getSrcDirectory } from "@latticexyz/common/foundry";
-import glob from "glob";
-import path, { basename } from "path";
+import path from "path";
 import { rmSync } from "fs";
+import { getExistingContracts } from "../utils";
 
 type Options = {
   configPath?: string;
@@ -36,11 +36,7 @@ const commandModule: CommandModule<Options, Options> = {
 export async function worldgenHandler(args: Options) {
   const srcDir = args.srcDir ?? (await getSrcDirectory());
 
-  // Get a list of all contract names
-  const existingContracts = glob.sync(`${srcDir}/**/*.sol`).map((path) => ({
-    path,
-    basename: basename(path, ".sol"),
-  }));
+  const existingContracts = getExistingContracts(srcDir);
 
   // Load the config
   const mudConfig = args.config ?? ((await loadConfig(args.configPath)) as StoreConfig & WorldConfig);
diff --git a/packages/cli/src/utils/deploy.ts b/packages/cli/src/utils/deploy.ts
index 0da123788e..018f38effe 100644
--- a/packages/cli/src/utils/deploy.ts
+++ b/packages/cli/src/utils/deploy.ts
@@ -12,7 +12,6 @@ import { StoreConfig } from "@latticexyz/store";
 import { resolveAbiOrUserType } from "@latticexyz/store/codegen";
 import { WorldConfig, resolveWorldConfig } from "@latticexyz/world";
 import { IBaseWorld } from "@latticexyz/world/types/ethers-contracts/IBaseWorld";
-
 import WorldData from "@latticexyz/world/abi/World.sol/World.json" assert { type: "json" };
 import IBaseWorldData from "@latticexyz/world/abi/IBaseWorld.sol/IBaseWorld.json" assert { type: "json" };
 import CoreModuleData from "@latticexyz/world/abi/CoreModule.sol/CoreModule.json" assert { type: "json" };
@@ -39,10 +38,10 @@ export interface DeploymentInfo {
 
 export async function deploy(
   mudConfig: StoreConfig & WorldConfig,
-  existingContracts: string[],
+  existingContractNames: string[],
   deployConfig: DeployConfig
 ): Promise<DeploymentInfo> {
-  const resolvedConfig = resolveWorldConfig(mudConfig, existingContracts);
+  const resolvedConfig = resolveWorldConfig(mudConfig, existingContractNames);
 
   const startTime = Date.now();
   const { worldContractName, namespace, postDeployScript } = mudConfig;
diff --git a/packages/cli/src/utils/deployHandler.ts b/packages/cli/src/utils/deployHandler.ts
index b61c56d3e7..b3a57cf7fb 100644
--- a/packages/cli/src/utils/deployHandler.ts
+++ b/packages/cli/src/utils/deployHandler.ts
@@ -1,6 +1,5 @@
 import chalk from "chalk";
-import glob from "glob";
-import path, { basename } from "path";
+import path from "path";
 import { MUDError } from "@latticexyz/common/errors";
 import { loadConfig } from "@latticexyz/config/node";
 import { StoreConfig } from "@latticexyz/store";
@@ -9,6 +8,7 @@ import { deploy } from "../utils/deploy";
 import { cast, forge, getRpcUrl, getSrcDirectory } from "@latticexyz/common/foundry";
 import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
 import { getChainId } from "../utils/getChainId";
+import { getExistingContracts } from "./getExistingContracts";
 
 export type DeployOptions = {
   configPath?: string;
@@ -27,7 +27,7 @@ export type DeployOptions = {
 };
 
 export async function deployHandler(args: DeployOptions) {
-  args.profile = args.profile ?? process.env.FOUNDRY_PROFILE;
+  args.profile ??= process.env.FOUNDRY_PROFILE;
   const { configPath, printConfig, profile, clean, skipBuild } = args;
 
   const rpc = args.rpc ?? (await getRpcUrl(profile));
@@ -44,10 +44,7 @@ export async function deployHandler(args: DeployOptions) {
 
   // Get a list of all contract names
   const srcDir = args?.srcDir ?? (await getSrcDirectory());
-  const existingContracts = glob
-    .sync(`${srcDir}/**/*.sol`)
-    // Get the basename of the file
-    .map((path) => basename(path, ".sol"));
+  const existingContractNames = getExistingContracts(srcDir).map(({ basename }) => basename);
 
   // Load the config
   const mudConfig = (await loadConfig(configPath)) as StoreConfig & WorldConfig;
@@ -61,7 +58,7 @@ export async function deployHandler(args: DeployOptions) {
 Run 'echo "PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" > .env'
 in your contracts directory to use the default anvil private key.`
     );
-  const deploymentInfo = await deploy(mudConfig, existingContracts, { ...args, rpc, privateKey });
+  const deploymentInfo = await deploy(mudConfig, existingContractNames, { ...args, rpc, privateKey });
 
   if (args.saveDeployment) {
     // Write deployment result to file (latest and timestamp)
diff --git a/packages/cli/src/utils/getExistingContracts.ts b/packages/cli/src/utils/getExistingContracts.ts
new file mode 100644
index 0000000000..ac3f67d982
--- /dev/null
+++ b/packages/cli/src/utils/getExistingContracts.ts
@@ -0,0 +1,12 @@
+import glob from "glob";
+import { basename } from "path";
+
+/**
+ * Get a list of all contract paths/names within the provided src directory
+ */
+export function getExistingContracts(srcDir: string) {
+  return glob.sync(`${srcDir}/**/*.sol`).map((path) => ({
+    path,
+    basename: basename(path, ".sol"),
+  }));
+}
diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts
index b1824ac827..3ac405bcf3 100644
--- a/packages/cli/src/utils/index.ts
+++ b/packages/cli/src/utils/index.ts
@@ -1,5 +1,7 @@
-export * from "./errors";
 export * from "./deploy";
 export * from "./deployHandler";
-export * from "./worldtypes";
+export * from "./errors";
+export * from "./getChainId";
+export * from "./getExistingContracts";
 export * from "./printMUD";
+export * from "./worldtypes";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8ec5832cc1..0ad6976f05 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -92,6 +92,9 @@ importers:
       '@latticexyz/store':
         specifier: workspace:*
         version: link:../store
+      '@latticexyz/utils':
+        specifier: workspace:*
+        version: link:../utils
       '@latticexyz/world':
         specifier: workspace:*
         version: link:../world