From 7eeb5eb4f920cefd02df6a5033926c9fba0f7793 Mon Sep 17 00:00:00 2001
From: Aidan Blum Levine <aidanblumlevine@gmail.com>
Date: Tue, 9 Apr 2024 21:13:22 -0400
Subject: [PATCH 1/5] refactor controls-bar

---
 client/src/app-context.tsx                    |   8 +-
 .../controls-bar/controls-bar-timeline.tsx    |  11 +-
 .../components/controls-bar/controls-bar.tsx  | 171 ++++++------------
 client/src/components/game/GameController.ts  |  56 ++++++
 client/src/components/game/game-area.tsx      |  62 ++++++-
 client/src/pages/main-page.tsx                |   5 +-
 client/src/playback/Match.ts                  |   4 +
 7 files changed, 179 insertions(+), 138 deletions(-)
 create mode 100644 client/src/components/game/GameController.ts

diff --git a/client/src/app-context.tsx b/client/src/app-context.tsx
index cea23d2f..eebe5817 100644
--- a/client/src/app-context.tsx
+++ b/client/src/app-context.tsx
@@ -11,8 +11,8 @@ export interface AppState {
     tournament: Tournament | undefined
     tournamentState: TournamentState
     loadingRemoteContent: string
-    updatesPerSecond: number
-    paused: boolean
+    // updatesPerSecond: number
+    // paused: boolean
     disableHotkeys: boolean
     config: ClientConfig
 }
@@ -24,8 +24,8 @@ const DEFAULT_APP_STATE: AppState = {
     tournament: undefined,
     tournamentState: DEFAULT_TOURNAMENT_STATE,
     loadingRemoteContent: '',
-    updatesPerSecond: 1,
-    paused: true,
+    // updatesPerSecond: 1,
+    // paused: true,
     disableHotkeys: false,
     config: getDefaultConfig()
 }
diff --git a/client/src/components/controls-bar/controls-bar-timeline.tsx b/client/src/components/controls-bar/controls-bar-timeline.tsx
index c1e8583d..70b59f1b 100644
--- a/client/src/components/controls-bar/controls-bar-timeline.tsx
+++ b/client/src/components/controls-bar/controls-bar-timeline.tsx
@@ -3,10 +3,11 @@ import { useAppContext } from '../../app-context'
 
 const TIMELINE_WIDTH = 350
 interface Props {
-    currentUPS: number
+    liveUPS: number
+    targetUPS: number
 }
 
-export const ControlsBarTimeline: React.FC<Props> = ({ currentUPS }) => {
+export const ControlsBarTimeline: React.FC<Props> = ({ liveUPS, targetUPS }) => {
     const appContext = useAppContext()
 
     let down = useRef(false)
@@ -67,9 +68,9 @@ export const ControlsBarTimeline: React.FC<Props> = ({ currentUPS }) => {
     return (
         <div className="min-h-[30px] bg-bg rounded-md mr-2 relative" style={{ minWidth: TIMELINE_WIDTH }}>
             <p className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-[10px] text-xs select-none whitespace-nowrap">
-                Turn: <b>{turn}</b>/{maxTurn} &nbsp; {appContext.state.updatesPerSecond} UPS (
-                {appContext.state.updatesPerSecond < 0 && '-'}
-                {currentUPS})
+                Turn: <b>{turn}</b>/{maxTurn} &nbsp; {targetUPS} UPS (
+                {targetUPS < 0 && '-'}
+                {liveUPS})
             </p>
             <div className="absolute bg-white/10 left-0 right-0 bottom-0 min-h-[5px] rounded"></div>
             <div
diff --git a/client/src/components/controls-bar/controls-bar.tsx b/client/src/components/controls-bar/controls-bar.tsx
index 07a1a039..fdd455df 100644
--- a/client/src/components/controls-bar/controls-bar.tsx
+++ b/client/src/components/controls-bar/controls-bar.tsx
@@ -7,130 +7,66 @@ import { ControlsBarTimeline } from './controls-bar-timeline'
 import { EventType, useListenEvent } from '../../app-events'
 import { useForceUpdate } from '../../util/react-util'
 import Tooltip from '../tooltip'
-import { PageType, usePage } from '../../app-search-params'
-
-const SIMULATION_UPDATE_INTERVAL_MS = 17 // About 60 fps
+import Match from '../../playback/Match'
+
+type ControlsBarProps = {
+    match: Match | undefined
+    paused: boolean
+    setPaused: (paused: boolean) => void
+    targetUPS: number
+    liveUPS: number
+    setTargetUPS: (targetUPS: number) => void
+    nextMatch: () => void
+    closeGame: () => void
+}
 
-export const ControlsBar: React.FC = () => {
+export const ControlsBar: React.FC<ControlsBarProps> = ({
+    match,
+    paused,
+    setPaused,
+    targetUPS,
+    setTargetUPS,
+    liveUPS,
+    nextMatch,
+    closeGame
+}) => {
     const { state: appState, setState: setAppState } = useAppContext()
     const [minimized, setMinimized] = React.useState(false)
     const keyboard = useKeyboard()
-    const [page, setPage] = usePage()
 
-    const currentUPSBuffer = React.useRef<number[]>([])
-
-    const currentMatch = appState.activeGame?.currentMatch
-    const isPlayable = appState.activeGame && appState.activeGame.playable && currentMatch
-    const hasNextMatch =
-        currentMatch && appState.activeGame!.matches.indexOf(currentMatch!) + 1 < appState.activeGame!.matches.length
+    const forceUpdate = useForceUpdate()
+    useListenEvent(EventType.TURN_PROGRESS, forceUpdate)
 
-    const changePaused = (paused: boolean) => {
-        if (!currentMatch) return
-        setAppState((prevState) => ({
-            ...prevState,
-            paused: paused,
-            updatesPerSecond: appState.updatesPerSecond == 0 && !paused ? 1 : appState.updatesPerSecond
-        }))
-    }
+    const hasNextMatch = match && match.game.matches.indexOf(match!) + 1 < match.game.matches.length
 
     const multiplyUpdatesPerSecond = (multiplier: number) => {
-        if (!isPlayable) return
-        setAppState((old) => {
-            const u = old.updatesPerSecond
-            const sign = Math.sign(u * multiplier)
-            const newMag = Math.max(1 / 4, Math.min(64, Math.abs(u * multiplier)))
-            return { ...old, updatesPerSecond: sign * newMag }
-        })
+        if (!match?.isPlayable()) return
+        const sign = Math.sign(targetUPS * multiplier)
+        const newMag = Math.max(1 / 4, Math.min(64, Math.abs(targetUPS * multiplier)))
+        setTargetUPS(sign * newMag)
     }
 
     const stepTurn = (delta: number) => {
-        if (!isPlayable) return
-        // explicit rerender at the end so a render doesnt occur between these two steps
-        currentMatch!.stepTurn(delta, false)
-        currentMatch!.roundSimulation()
-        currentMatch!.rerender()
+        if (!match?.isPlayable()) return
+        match.stepTurn(delta, false) // false to not rerender here, so we can round first
+        match.roundSimulation()
+        match.rerender()
     }
 
     const jumpToTurn = (turn: number) => {
-        if (!isPlayable) return
-        // explicit rerender at the end so a render doesnt occur between these two steps
-        currentMatch!.jumpToTurn(turn, false)
-        currentMatch!.roundSimulation()
-        currentMatch!.rerender()
+        if (!match?.isPlayable()) return
+        match.jumpToTurn(turn, false) // false to not rerender here, so we can round first
+        match.roundSimulation()
+        match.rerender()
     }
 
     const jumpToEnd = () => {
-        if (!isPlayable) return
-        // explicit rerender at the end so a render doesnt occur between these two steps
-        currentMatch!.jumpToEnd(false)
-        currentMatch!.roundSimulation()
-        currentMatch!.rerender()
+        if (!match?.isPlayable()) return
+        match.jumpToEnd(false) // false to not rerender here, so we can round first
+        match.roundSimulation()
+        match.rerender()
     }
 
-    const nextMatch = () => {
-        if (!isPlayable) return
-        const game = appState.activeGame!
-        const prevMatch = game.currentMatch!
-        const prevMatchIndex = game.matches.indexOf(prevMatch)
-        if (prevMatchIndex + 1 == game.matches.length) {
-            closeGame()
-            return
-        }
-
-        game.currentMatch = game.matches[prevMatchIndex + 1]
-        setAppState((prevState) => ({
-            ...prevState,
-            activeGame: game,
-            activeMatch: game.currentMatch
-        }))
-    }
-
-    const closeGame = () => {
-        setAppState((prevState) => ({
-            ...prevState,
-            activeGame: undefined,
-            activeMatch: undefined
-        }))
-        if (appState.tournament) setPage(PageType.TOURNAMENT)
-    }
-
-    React.useEffect(() => {
-        // We want to pause whenever the match changes
-        changePaused(true)
-    }, [currentMatch])
-
-    React.useEffect(() => {
-        if (!isPlayable) return
-        if (appState.paused) {
-            // Snap bots to their actual position when paused by rounding simulation
-            // to the true turn
-            currentMatch!.roundSimulation()
-            currentMatch!.rerender()
-            return
-        }
-
-        const msPerUpdate = 1000 / appState.updatesPerSecond
-        const updatesPerInterval = SIMULATION_UPDATE_INTERVAL_MS / msPerUpdate
-        const stepInterval = setInterval(() => {
-            const prevTurn = currentMatch!.currentTurn.turnNumber
-            currentMatch!.stepSimulation(updatesPerInterval)
-
-            if (prevTurn != currentMatch!.currentTurn.turnNumber) {
-                currentUPSBuffer.current.push(Date.now())
-                while (currentUPSBuffer.current.length > 0 && currentUPSBuffer.current[0] < Date.now() - 1000)
-                    currentUPSBuffer.current.shift()
-            }
-
-            if (currentMatch!.currentTurn.isEnd() && appState.updatesPerSecond > 0) {
-                changePaused(true)
-            }
-        }, SIMULATION_UPDATE_INTERVAL_MS)
-
-        return () => {
-            clearInterval(stepInterval)
-        }
-    }, [appState.updatesPerSecond, appState.activeGame, currentMatch, appState.paused])
-
     useEffect(() => {
         if (appState.disableHotkeys) return
 
@@ -139,12 +75,12 @@ export const ControlsBar: React.FC = () => {
         // specific accessibility features that mess with these shortcuts.
         if (keyboard.targetElem instanceof HTMLButtonElement) keyboard.targetElem.blur()
 
-        if (keyboard.keyCode === 'Space') changePaused(!appState.paused)
+        if (keyboard.keyCode === 'Space' && match) setPaused(!paused)
 
         if (keyboard.keyCode === 'KeyC') setMinimized(!minimized)
 
         const applyArrows = () => {
-            if (appState.paused) {
+            if (paused) {
                 if (keyboard.keyCode === 'ArrowRight') stepTurn(1)
                 if (keyboard.keyCode === 'ArrowLeft') stepTurn(-1)
             } else {
@@ -170,13 +106,10 @@ export const ControlsBar: React.FC = () => {
         }
     }, [keyboard.keyCode])
 
-    const forceUpdate = useForceUpdate()
-    useListenEvent(EventType.TURN_PROGRESS, forceUpdate)
-
-    if (!isPlayable) return null
+    if (!match?.isPlayable()) return null
 
-    const atStart = currentMatch.currentTurn.turnNumber == 0
-    const atEnd = currentMatch.currentTurn.turnNumber == currentMatch.maxTurn
+    const atStart = match.currentTurn.turnNumber == 0
+    const atEnd = match.currentTurn.turnNumber == match.maxTurn
 
     return (
         <div
@@ -200,7 +133,7 @@ export const ControlsBar: React.FC = () => {
                     ' flex bg-darkHighlight text-white p-1.5 rounded-t-md z-10 gap-1.5 relative'
                 }
             >
-                <ControlsBarTimeline currentUPS={currentUPSBuffer.current.length} />
+                <ControlsBarTimeline liveUPS={liveUPS} targetUPS={targetUPS} />
                 <ControlsBarButton
                     icon={<ControlIcons.ReverseIcon />}
                     tooltip="Reverse"
@@ -210,7 +143,7 @@ export const ControlsBar: React.FC = () => {
                     icon={<ControlIcons.SkipBackwardsIcon />}
                     tooltip={'Decrease Speed'}
                     onClick={() => multiplyUpdatesPerSecond(0.5)}
-                    disabled={Math.abs(appState.updatesPerSecond) <= 0.25}
+                    disabled={Math.abs(targetUPS) <= 0.25}
                 />
                 <ControlsBarButton
                     icon={<ControlIcons.GoPreviousIcon />}
@@ -218,12 +151,12 @@ export const ControlsBar: React.FC = () => {
                     onClick={() => stepTurn(-1)}
                     disabled={atStart}
                 />
-                {appState.paused ? (
+                {paused ? (
                     <ControlsBarButton
                         icon={<ControlIcons.PlaybackPlayIcon />}
                         tooltip="Play"
                         onClick={() => {
-                            changePaused(false)
+                            setPaused(false)
                         }}
                     />
                 ) : (
@@ -231,7 +164,7 @@ export const ControlsBar: React.FC = () => {
                         icon={<ControlIcons.PlaybackPauseIcon />}
                         tooltip="Pause"
                         onClick={() => {
-                            changePaused(true)
+                            setPaused(true)
                         }}
                     />
                 )}
@@ -245,7 +178,7 @@ export const ControlsBar: React.FC = () => {
                     icon={<ControlIcons.SkipForwardsIcon />}
                     tooltip={'Increase Speed'}
                     onClick={() => multiplyUpdatesPerSecond(2)}
-                    disabled={Math.abs(appState.updatesPerSecond) >= 64}
+                    disabled={Math.abs(targetUPS) >= 64}
                 />
                 <ControlsBarButton
                     icon={<ControlIcons.PlaybackStopIcon />}
diff --git a/client/src/components/game/GameController.ts b/client/src/components/game/GameController.ts
new file mode 100644
index 00000000..a37b10af
--- /dev/null
+++ b/client/src/components/game/GameController.ts
@@ -0,0 +1,56 @@
+import { useEffect, useRef, useState } from 'react'
+import Match from '../../playback/Match'
+
+const SIMULATION_UPDATE_INTERVAL_MS = 17 // About 60 fps
+
+export const useSimulationControl = (currentMatch: Match | undefined) => {
+    const [paused, setPaused] = useState(true)
+    const [targetUPS, setTargetUPS] = useState(1)
+    const [liveUPS, setLiveUPS] = useState(0) // State to keep track of liveUPS
+
+    const currentUPSBuffer = useRef<number[]>([])
+
+    useEffect(() => {
+        setPaused(true) // Pause when match changes
+        currentUPSBuffer.current = [] // Clear the buffer when match changes
+        setLiveUPS(0) // Reset liveUPS when match changes
+    }, [currentMatch])
+
+    useEffect(() => {
+        if (!currentMatch?.isPlayable()) return
+
+        if (paused) {
+            currentMatch.roundSimulation()
+            currentMatch.rerender()
+            return
+        }
+
+        const msPerUpdate = 1000 / targetUPS
+        const updatesPerInterval = SIMULATION_UPDATE_INTERVAL_MS / msPerUpdate
+        const stepInterval = setInterval(() => {
+            const prevTurn = currentMatch.currentTurn.turnNumber
+            currentMatch.stepSimulation(updatesPerInterval)
+            if (prevTurn !== currentMatch.currentTurn.turnNumber) {
+                currentUPSBuffer.current.push(Date.now())
+                while (currentUPSBuffer.current.length > 0 && currentUPSBuffer.current[0] < Date.now() - 1000) {
+                    currentUPSBuffer.current.shift()
+                }
+                setLiveUPS(currentUPSBuffer.current.length) // Update liveUPS
+            }
+            if (currentMatch.currentTurn.isEnd() && targetUPS > 0) setPaused(true)
+        }, SIMULATION_UPDATE_INTERVAL_MS)
+
+        return () => {
+            clearInterval(stepInterval)
+        }
+    }, [targetUPS, currentMatch, paused])
+
+    return {
+        paused,
+        setPaused,
+        targetUPS,
+        setTargetUPS,
+        liveUPS
+    }
+}
+
diff --git a/client/src/components/game/game-area.tsx b/client/src/components/game/game-area.tsx
index 65df02cd..4e378114 100644
--- a/client/src/components/game/game-area.tsx
+++ b/client/src/components/game/game-area.tsx
@@ -1,20 +1,70 @@
-import React from 'react'
+import React, { useEffect, useRef, useState } from 'react'
 import { GameRenderer } from './game-renderer'
 import { useAppContext } from '../../app-context'
 import { TournamentRenderer } from './tournament-renderer/tournament-renderer'
+import { ControlsBar } from '../controls-bar/controls-bar'
+import { useSimulationControl } from './GameController';
 
-export const GameArea: React.FC = () => {
-    const appContext = useAppContext()
 
-    if (appContext.state.loadingRemoteContent) {
+export const GameArea = () => {
+    const { state: appState, setState: setAppState } = useAppContext()
+    const currentMatch = appState.activeGame?.currentMatch
+    const { paused, setPaused, targetUPS, setTargetUPS, liveUPS } = useSimulationControl(currentMatch)
+
+    const handleNextMatch = () => {
+        if (!currentMatch) return
+        const game = currentMatch.game
+        const prevMatchIndex = game.matches.indexOf(currentMatch)
+        if (game.matches[prevMatchIndex + 1] === undefined) return
+        game.currentMatch = game.matches[prevMatchIndex + 1]
+        setAppState((prevState) => ({
+            ...prevState,
+            activeGame: game,
+            activeMatch: game.currentMatch
+        }))
+    }
+
+    const handleCloseGame = () => {
+        setAppState((prevState) => ({
+            ...prevState,
+            activeGame: undefined,
+            activeMatch: undefined
+        }))
+    }
+
+    return (
+        <div className="w-full h-screen flex justify-center">
+            <GameCanvasArea
+                loadingRemoteContent={appState.loadingRemoteContent}
+                tournamentScreen={!appState.activeGame && !!appState.tournament}
+            />
+            <ControlsBar
+                match={currentMatch}
+                paused={paused}
+                setPaused={setPaused}
+                targetUPS={targetUPS}
+                setTargetUPS={setTargetUPS}
+                liveUPS={liveUPS}
+                nextMatch={handleNextMatch}
+                closeGame={handleCloseGame}
+            />
+        </div>
+    )
+}
+
+const GameCanvasArea: React.FC<{ loadingRemoteContent: string; tournamentScreen: boolean }> = ({
+    loadingRemoteContent,
+    tournamentScreen
+}) => {
+    if (loadingRemoteContent) {
         return (
             <div className="relative w-full h-screen flex items-center justify-center">
-                <p className="text-white text-center">{`Loading remote ${appContext.state.loadingRemoteContent}...`}</p>
+                <p className="text-white text-center">{`Loading remote ${loadingRemoteContent}...`}</p>
             </div>
         )
     }
 
-    if (!appContext.state.activeGame && appContext.state.tournament) {
+    if (tournamentScreen) {
         return <TournamentRenderer />
     }
 
diff --git a/client/src/pages/main-page.tsx b/client/src/pages/main-page.tsx
index 53e12986..2f61e002 100644
--- a/client/src/pages/main-page.tsx
+++ b/client/src/pages/main-page.tsx
@@ -10,10 +10,7 @@ export const MainPage: React.FC = () => {
         <AppContextProvider>
             <div className="flex overflow-hidden" style={{ backgroundColor: GAMEAREA_BACKGROUND }}>
                 <Sidebar />
-                <div className="w-full h-screen flex justify-center">
-                    <GameArea />
-                    <ControlsBar />
-                </div>
+                <GameArea />
             </div>
         </AppContextProvider>
     )
diff --git a/client/src/playback/Match.ts b/client/src/playback/Match.ts
index 0ddb1fe0..d1c3f8b1 100644
--- a/client/src/playback/Match.ts
+++ b/client/src/playback/Match.ts
@@ -86,6 +86,10 @@ export default class Match {
         return match
     }
 
+    public isPlayable() {
+        return this.game.playable
+    }
+
     /*
      * Add a new turn to the match. Used for live match replaying.
      */

From c9f075537b55c244f5a177e6ebcc4e19a8c434c3 Mon Sep 17 00:00:00 2001
From: Aidan Blum Levine <aidanblumlevine@gmail.com>
Date: Wed, 10 Apr 2024 14:24:18 -0400
Subject: [PATCH 2/5] refactor event loop hooks and tooltip

---
 client/src/app-events.tsx                     |  10 +-
 .../components/controls-bar/controls-bar.tsx  |   2 +-
 client/src/components/game/GameController.ts  |   2 +-
 client/src/components/game/game-area.tsx      |   6 +-
 client/src/components/game/game-renderer.tsx  | 322 +++++++++---------
 client/src/components/game/overlay.tsx        | 262 ++++++++++++++
 client/src/components/game/tooltip.tsx        | 197 -----------
 client/src/components/sidebar/game/game.tsx   |   2 +-
 .../src/components/sidebar/game/histogram.tsx |   2 +-
 .../sidebar/game/resource-graph.tsx           |   2 +-
 .../components/sidebar/game/team-table.tsx    |   2 +-
 .../sidebar/map-editor/map-editor.tsx         |   2 +-
 .../components/sidebar/runner/websocket.ts    |   4 +-
 client/src/playback/Match.ts                  |   2 +-
 14 files changed, 444 insertions(+), 373 deletions(-)
 delete mode 100644 client/src/components/game/tooltip.tsx

diff --git a/client/src/app-events.tsx b/client/src/app-events.tsx
index 4a8e82ff..7e2e7a39 100644
--- a/client/src/app-events.tsx
+++ b/client/src/app-events.tsx
@@ -1,12 +1,12 @@
 import { useEffect } from 'react'
 
 export enum EventType {
-    TURN_PROGRESS = 'turnprogress',
-    TILE_CLICK = 'tileclick',
+    NEW_TURN = 'NEW_TURN',
+    TILE_CLICK = 'TILE_CLICK',
     TILE_DRAG = 'TILE_DRAG',
     CANVAS_RIGHT_CLICK = 'CANVAS_RIGHT_CLICK',
-    RENDER = 'render',
-    INITIAL_RENDER = 'initalrender'
+    RENDER = 'RENDER',
+    MAP_RENDER = 'MAP_RENDER'
 }
 
 export function useListenEvent(
@@ -26,7 +26,7 @@ export function useListenEvent(
     }, deps)
 }
 
-export function publishEvent(eventType: string, eventData: any) {
+export function publishEvent(eventType: string, eventData: any = false) {
     const event = new CustomEvent(eventType as string, { detail: eventData })
     document.dispatchEvent(event)
 }
diff --git a/client/src/components/controls-bar/controls-bar.tsx b/client/src/components/controls-bar/controls-bar.tsx
index fdd455df..730c4fc2 100644
--- a/client/src/components/controls-bar/controls-bar.tsx
+++ b/client/src/components/controls-bar/controls-bar.tsx
@@ -35,7 +35,7 @@ export const ControlsBar: React.FC<ControlsBarProps> = ({
     const keyboard = useKeyboard()
 
     const forceUpdate = useForceUpdate()
-    useListenEvent(EventType.TURN_PROGRESS, forceUpdate)
+    useListenEvent(EventType.NEW_TURN, forceUpdate)
 
     const hasNextMatch = match && match.game.matches.indexOf(match!) + 1 < match.game.matches.length
 
diff --git a/client/src/components/game/GameController.ts b/client/src/components/game/GameController.ts
index a37b10af..ef0cfe5e 100644
--- a/client/src/components/game/GameController.ts
+++ b/client/src/components/game/GameController.ts
@@ -3,7 +3,7 @@ import Match from '../../playback/Match'
 
 const SIMULATION_UPDATE_INTERVAL_MS = 17 // About 60 fps
 
-export const useSimulationControl = (currentMatch: Match | undefined) => {
+export const useGameControl = (currentMatch: Match | undefined) => {
     const [paused, setPaused] = useState(true)
     const [targetUPS, setTargetUPS] = useState(1)
     const [liveUPS, setLiveUPS] = useState(0) // State to keep track of liveUPS
diff --git a/client/src/components/game/game-area.tsx b/client/src/components/game/game-area.tsx
index 4e378114..83e09d48 100644
--- a/client/src/components/game/game-area.tsx
+++ b/client/src/components/game/game-area.tsx
@@ -1,15 +1,15 @@
-import React, { useEffect, useRef, useState } from 'react'
+import React from 'react'
 import { GameRenderer } from './game-renderer'
 import { useAppContext } from '../../app-context'
 import { TournamentRenderer } from './tournament-renderer/tournament-renderer'
 import { ControlsBar } from '../controls-bar/controls-bar'
-import { useSimulationControl } from './GameController';
+import { useGameControl } from './GameController';
 
 
 export const GameArea = () => {
     const { state: appState, setState: setAppState } = useAppContext()
     const currentMatch = appState.activeGame?.currentMatch
-    const { paused, setPaused, targetUPS, setTargetUPS, liveUPS } = useSimulationControl(currentMatch)
+    const { paused, setPaused, targetUPS, setTargetUPS, liveUPS } = useGameControl(currentMatch)
 
     const handleNextMatch = () => {
         if (!currentMatch) return
diff --git a/client/src/components/game/game-renderer.tsx b/client/src/components/game/game-renderer.tsx
index c3017f8b..97793993 100644
--- a/client/src/components/game/game-renderer.tsx
+++ b/client/src/components/game/game-renderer.tsx
@@ -1,11 +1,12 @@
-import React, { useEffect, useRef, useState } from 'react'
+import React, { MutableRefObject, useEffect, useRef, useState } from 'react'
 import { useAppContext } from '../../app-context'
 import { Vector } from '../../playback/Vector'
 import { EventType, publishEvent, useListenEvent } from '../../app-events'
-import assert from 'assert'
-import { Tooltip } from './tooltip'
+import { Overlay } from './overlay'
 import { TILE_RESOLUTION } from '../../constants'
 import { CurrentMap } from '../../playback/Map'
+import Match from '../../playback/Match'
+import { ClientConfig } from '../../client-config'
 
 export const GameRenderer: React.FC = () => {
     const wrapperRef = useRef<HTMLDivElement | null>(null)
@@ -14,84 +15,155 @@ export const GameRenderer: React.FC = () => {
     const overlayCanvas = useRef<HTMLCanvasElement | null>(null)
 
     const appContext = useAppContext()
-    const { activeGame, activeMatch } = appContext.state
+    const { activeMatch, config } = appContext.state
 
-    const [selectedBodyID, setSelectedBodyID] = useState<number | undefined>(undefined)
-    const [hoveredTile, setHoveredTile] = useState<Vector | undefined>(undefined)
-    const [selectedSquare, setSelectedSquare] = useState<Vector | undefined>(undefined)
+    const {
+        onMouseMove,
+        onMouseDown,
+        onMouseUp,
+        onMouseLeave,
+        onMouseEnter,
+        onCanvasClick,
+        selectedBodyID,
+        hoveredTile,
+        hoveredBodyID
+    } = useRenderEvents(activeMatch, config, backgroundCanvas, dynamicCanvas, overlayCanvas)
+
+    return (
+        <div
+            className="relative w-full h-screen flex items-center justify-center"
+            style={{ WebkitUserSelect: 'none', userSelect: 'none' }}
+            ref={wrapperRef}
+        >
+            {!activeMatch ? (
+                <p className="text-white text-center">Select a game from the queue</p>
+            ) : (
+                <>
+                    <canvas
+                        className="absolute top-1/2 left-1/2 max-w-full max-h-full"
+                        style={{
+                            transform: 'translate(-50%, -50%)',
+                            zIndex: 0,
+                            cursor: 'pointer'
+                        }}
+                        ref={backgroundCanvas}
+                    />
+                    <canvas
+                        className="absolute top-1/2 left-1/2 max-w-full max-h-full"
+                        style={{
+                            transform: 'translate(-50%, -50%)',
+                            zIndex: 1,
+                            cursor: 'pointer'
+                        }}
+                        ref={dynamicCanvas}
+                    />
+                    <canvas
+                        className="absolute top-1/2 left-1/2 max-w-full max-h-full"
+                        style={{
+                            transform: 'translate(-50%, -50%)',
+                            zIndex: 2,
+                            cursor: 'pointer'
+                        }}
+                        ref={overlayCanvas}
+                        onClick={onCanvasClick}
+                        onMouseMove={onMouseMove}
+                        onMouseDown={onMouseDown}
+                        onMouseUp={onMouseUp}
+                        onMouseLeave={onMouseLeave}
+                        onMouseEnter={onMouseEnter}
+                        onContextMenu={(e) => {
+                            e.preventDefault()
+                        }}
+                    />
+                    {overlayCanvas.current && wrapperRef.current && activeMatch && (
+                        <Overlay
+                            match={activeMatch}
+                            overlayCanvas={overlayCanvas.current}
+                            selectedBodyID={selectedBodyID}
+                            hoveredBodyID={hoveredBodyID}
+                            hoveredTile={hoveredTile}
+                            wrapperRef={wrapperRef.current}
+                        />
+                    )}
+                </>
+            )}
+        </div>
+    )
+}
+
+const useHoveredBody = (hoveredTile: Vector | undefined, match: Match | undefined) => {
     const [hoveredBodyID, setHoveredBodyID] = useState<number | undefined>(undefined)
     const calculateHoveredBodyID = () => {
         if (!hoveredTile) return setHoveredBodyID(undefined)
-        const match = appContext.state.activeMatch
         if (!match) return
-        const hoveredBodyIDFound = match.currentTurn.bodies.getBodyAtLocation(hoveredTile.x, hoveredTile.y)?.id
-        setHoveredBodyID(hoveredBodyIDFound)
+        const hoveredBody = match.currentTurn.bodies.getBodyAtLocation(hoveredTile.x, hoveredTile.y)
+        setHoveredBodyID(hoveredBody?.id)
+    }
+    useEffect(calculateHoveredBodyID, [hoveredTile, match])
+    useListenEvent(EventType.NEW_TURN, calculateHoveredBodyID)
+    return hoveredBodyID
+}
+
+const useRenderEvents = (
+    match: Match | undefined,
+    config: ClientConfig,
+    backgroundCanvas: MutableRefObject<HTMLCanvasElement | null>,
+    dynamicCanvas: MutableRefObject<HTMLCanvasElement | null>,
+    overlayCanvas: MutableRefObject<HTMLCanvasElement | null>
+) => {
+    const [selectedBodyID, setSelectedBodyID] = useState<number | undefined>(undefined)
+    const [hoveredTile, setHoveredTile] = useState<Vector | undefined>(undefined)
+    const mouseDown = React.useRef(false)
+    const mouseDownRightPrev = React.useRef(false)
+    const lastFiredDragEvent = React.useRef({ x: -1, y: -1 })
+    const hoveredBodyID = useHoveredBody(hoveredTile, match)
 
-        // always clear this so the selection is cleared when you move
-        setSelectedSquare(undefined)
+    const lastRender = useRef(0)
+    const queueTimeout = useRef<NodeJS.Timeout | null>(null)
+    const queueRender = () => {
+        if (queueTimeout.current) clearTimeout(queueTimeout.current)
+        if (Date.now() - lastRender.current > 1000 / 60) {
+            render()
+        } else {
+            queueTimeout.current = setTimeout(render, 1000 / 60)
+        }
     }
-    useEffect(calculateHoveredBodyID, [hoveredTile])
-    useListenEvent(EventType.TURN_PROGRESS, calculateHoveredBodyID)
 
-    const render = () => {
+    const render = (full: boolean = false) => {
         const ctx = dynamicCanvas.current?.getContext('2d')
         const overlayCtx = overlayCanvas.current?.getContext('2d')
-        if (!activeMatch || !ctx || !overlayCtx) return
+        const staticCtx = backgroundCanvas.current?.getContext('2d')
+        if (!match || !ctx || !overlayCtx || !staticCtx) return
+
+        lastRender.current = Date.now()
 
-        const currentTurn = activeMatch.currentTurn
-        const map = currentTurn.map
+        if (full) {
+            staticCtx.clearRect(0, 0, staticCtx.canvas.width, staticCtx.canvas.height)
+            match.currentTurn.map.staticMap.draw(staticCtx)
+        }
 
         ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
         overlayCtx.clearRect(0, 0, overlayCtx.canvas.width, overlayCtx.canvas.height)
-        map.draw(activeMatch, ctx, appContext.state.config, selectedBodyID, hoveredBodyID)
-        currentTurn.bodies.draw(activeMatch, ctx, overlayCtx, appContext.state.config, selectedBodyID, hoveredBodyID)
-        currentTurn.actions.draw(activeMatch, ctx)
+        match.currentTurn.map.draw(match, ctx, config, selectedBodyID, hoveredBodyID)
+        match.currentTurn.bodies.draw(match, ctx, overlayCtx, config, selectedBodyID, hoveredBodyID)
+        match.currentTurn.actions.draw(match, ctx)
     }
-    useEffect(render, [hoveredBodyID, selectedBodyID])
-    useListenEvent(EventType.RENDER, render, [render])
-
-    const fullRender = () => {
-        const match = appContext.state.activeMatch
-        const ctx = backgroundCanvas.current?.getContext('2d')
-        if (!match || !ctx) return
-        match.currentTurn.map.staticMap.draw(ctx)
-        render()
-    }
-    useListenEvent(EventType.INITIAL_RENDER, fullRender, [fullRender])
 
-    const updateCanvasDimensions = (canvas: HTMLCanvasElement | null, dims: Vector) => {
-        if (!canvas) return
-        canvas.width = dims.x * TILE_RESOLUTION
-        canvas.height = dims.y * TILE_RESOLUTION
-        canvas.getContext('2d')?.scale(TILE_RESOLUTION, TILE_RESOLUTION)
-    }
+    useEffect(queueRender, [hoveredBodyID, selectedBodyID])
+    useListenEvent(EventType.RENDER, queueRender, [render])
+    useListenEvent(EventType.MAP_RENDER, () => render(true))
+
     useEffect(() => {
-        const match = appContext.state.activeMatch
         if (!match) return
         const { width, height } = match.currentTurn.map
         updateCanvasDimensions(backgroundCanvas.current, { x: width, y: height })
         updateCanvasDimensions(dynamicCanvas.current, { x: width, y: height })
         updateCanvasDimensions(overlayCanvas.current, { x: width, y: height })
-        setSelectedSquare(undefined)
         setSelectedBodyID(undefined)
         setHoveredTile(undefined)
-        setHoveredBodyID(undefined)
-        publishEvent(EventType.INITIAL_RENDER, {})
-    }, [appContext.state.activeMatch, backgroundCanvas.current, dynamicCanvas.current, overlayCanvas.current])
-
-    const eventToPoint = (e: React.MouseEvent<HTMLCanvasElement, MouseEvent>) => {
-        const canvas = e.target as HTMLCanvasElement
-        const rect = canvas.getBoundingClientRect()
-        const map = activeGame!.currentMatch!.currentTurn!.map ?? assert.fail('map is null in onclick')
-        let x = Math.floor(((e.clientX - rect.left) / rect.width) * map.width)
-        let y = Math.floor((1 - (e.clientY - rect.top) / rect.height) * map.height)
-        x = Math.max(0, Math.min(x, map.width - 1))
-        y = Math.max(0, Math.min(y, map.height - 1))
-        return { x: x, y: y }
-    }
-    const mouseDown = React.useRef(false)
-    const mouseDownRightPrev = React.useRef(false)
-    const lastFiredDragEvent = React.useRef({ x: -1, y: -1 })
+        render(true)
+    }, [match, backgroundCanvas.current, dynamicCanvas.current, overlayCanvas.current])
+
     const onMouseUp = (e: React.MouseEvent<HTMLCanvasElement, MouseEvent>) => {
         mouseDown.current = false
         lastFiredDragEvent.current = { x: -1, y: -1 }
@@ -102,7 +174,8 @@ export const GameRenderer: React.FC = () => {
         if (e.button === 2) mouseDownRight(true, e)
     }
     const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement, MouseEvent>) => {
-        const tile = eventToPoint(e)
+        if (mouseDown.current) onCanvasDrag(e)
+        const tile = eventToPoint(e, match?.currentTurn.map)
         if (tile.x !== hoveredTile?.x || tile.y !== hoveredTile?.y) setHoveredTile(tile)
     }
     const mouseDownRight = (down: boolean, e?: React.MouseEvent<HTMLCanvasElement, MouseEvent>) => {
@@ -117,117 +190,50 @@ export const GameRenderer: React.FC = () => {
         setHoveredTile(undefined)
     }
     const onCanvasClick = (e: React.MouseEvent<HTMLCanvasElement, MouseEvent>) => {
-        const point = eventToPoint(e)
-        const clickedBody = activeGame?.currentMatch?.currentTurn?.bodies.getBodyAtLocation(point.x, point.y)
+        const point = eventToPoint(e, match?.currentTurn.map)
+        const clickedBody = match?.currentTurn.bodies.getBodyAtLocation(point.x, point.y)
         setSelectedBodyID(clickedBody ? clickedBody.id : undefined)
-        setSelectedSquare(clickedBody || !activeMatch?.game.playable ? undefined : point)
         publishEvent(EventType.TILE_CLICK, point)
     }
     const onCanvasDrag = (e: React.MouseEvent<HTMLCanvasElement, MouseEvent>) => {
-        const tile = eventToPoint(e)
+        const tile = eventToPoint(e, match?.currentTurn.map)
         if (tile.x !== hoveredTile?.x || tile.y !== hoveredTile?.y) setHoveredTile(tile)
         if (tile.x === lastFiredDragEvent.current.x && tile.y === lastFiredDragEvent.current.y) return
         lastFiredDragEvent.current = tile
         publishEvent(EventType.TILE_DRAG, tile)
     }
+    const onMouseEnter = (e: React.MouseEvent<HTMLCanvasElement, MouseEvent>) => {
+        if (e.buttons === 1) mouseDown.current = true
+    }
 
-    return (
-        <div
-            className="relative w-full h-screen flex items-center justify-center"
-            style={{ WebkitUserSelect: 'none', userSelect: 'none' }}
-            ref={wrapperRef}
-        >
-            {!activeMatch ? (
-                <p className="text-white text-center">Select a game from the queue</p>
-            ) : (
-                <>
-                    <canvas
-                        className="absolute top-1/2 left-1/2 max-w-full max-h-full"
-                        style={{
-                            transform: 'translate(-50%, -50%)',
-                            zIndex: 0,
-                            cursor: 'pointer'
-                        }}
-                        ref={backgroundCanvas}
-                    />
-                    <canvas
-                        className="absolute top-1/2 left-1/2 max-w-full max-h-full"
-                        style={{
-                            transform: 'translate(-50%, -50%)',
-                            zIndex: 1,
-                            cursor: 'pointer'
-                        }}
-                        ref={dynamicCanvas}
-                    />
-                    <canvas
-                        className="absolute top-1/2 left-1/2 max-w-full max-h-full"
-                        style={{
-                            transform: 'translate(-50%, -50%)',
-                            zIndex: 2,
-                            cursor: 'pointer'
-                        }}
-                        ref={overlayCanvas}
-                        onClick={onCanvasClick}
-                        onMouseMove={(e) => {
-                            if (mouseDown.current) onCanvasDrag(e)
-                            onMouseMove(e)
-                        }}
-                        onMouseDown={onMouseDown}
-                        onMouseUp={onMouseUp}
-                        onMouseLeave={onMouseLeave}
-                        onMouseEnter={(e) => {
-                            if (e.buttons === 1) mouseDown.current = true
-                        }}
-                        onContextMenu={(e) => {
-                            e.preventDefault()
-                        }}
-                    />
-                    <Tooltip
-                        overlayCanvas={overlayCanvas.current}
-                        selectedBodyID={selectedBodyID}
-                        hoveredBodyID={hoveredBodyID}
-                        hoveredSquare={hoveredTile}
-                        selectedSquare={selectedSquare}
-                        wrapperRef={wrapperRef.current}
-                    />
-                    <HighlightedSquare
-                        hoveredTile={hoveredTile}
-                        map={activeMatch?.currentTurn.map}
-                        wrapperRef={wrapperRef.current}
-                        overlayCanvasRef={overlayCanvas.current}
-                    />
-                </>
-            )}
-        </div>
-    )
+    return {
+        onMouseMove,
+        onMouseDown,
+        onMouseUp,
+        onMouseLeave,
+        onMouseEnter,
+        onCanvasClick,
+        onCanvasDrag,
+        selectedBodyID,
+        hoveredTile,
+        hoveredBodyID
+    }
 }
 
-interface HighlightedSquareProps {
-    overlayCanvasRef: HTMLCanvasElement | null
-    wrapperRef: HTMLDivElement | null
-    map?: CurrentMap
-    hoveredTile?: Vector
+const updateCanvasDimensions = (canvas: HTMLCanvasElement | null, dims: Vector) => {
+    if (!canvas) return
+    canvas.width = dims.x * TILE_RESOLUTION
+    canvas.height = dims.y * TILE_RESOLUTION
+    canvas.getContext('2d')?.scale(TILE_RESOLUTION, TILE_RESOLUTION)
 }
-const HighlightedSquare: React.FC<HighlightedSquareProps> = ({ overlayCanvasRef, wrapperRef, map, hoveredTile }) => {
-    if (!hoveredTile || !map || !wrapperRef || !overlayCanvasRef) return <></>
-    const overlayCanvasRect = overlayCanvasRef.getBoundingClientRect()
-    const wrapperRect = wrapperRef.getBoundingClientRect()
-    const mapLeft = overlayCanvasRect.left - wrapperRect.left
-    const mapTop = overlayCanvasRect.top - wrapperRect.top
-    const tileWidth = overlayCanvasRect.width / map.width
-    const tileHeight = overlayCanvasRect.height / map.height
-    const tileLeft = mapLeft + tileWidth * hoveredTile.x
-    const tileTop = mapTop + tileHeight * (map.height - hoveredTile.y - 1)
-    return (
-        <div
-            className="absolute border-2 border-black/70 z-10 cursor-pointer"
-            style={{
-                left: tileLeft + 'px',
-                top: tileTop + 'px',
-                width: overlayCanvasRect.width / map.width + 'px',
-                height: overlayCanvasRect.height / map.height + 'px',
-                pointerEvents: 'none'
-            }}
-        />
-    )
+
+const eventToPoint = (e: React.MouseEvent<HTMLCanvasElement, MouseEvent>, map: CurrentMap | undefined) => {
+    if (!map) throw new Error('Map is undefined in eventToPoint function')
+    const canvas = e.target as HTMLCanvasElement
+    const rect = canvas.getBoundingClientRect()
+    let x = Math.floor(((e.clientX - rect.left) / rect.width) * map.width)
+    let y = Math.floor((1 - (e.clientY - rect.top) / rect.height) * map.height)
+    x = Math.max(0, Math.min(x, map.width - 1))
+    y = Math.max(0, Math.min(y, map.height - 1))
+    return { x: x, y: y }
 }
diff --git a/client/src/components/game/overlay.tsx b/client/src/components/game/overlay.tsx
index e69de29b..d35413c0 100644
--- a/client/src/components/game/overlay.tsx
+++ b/client/src/components/game/overlay.tsx
@@ -0,0 +1,262 @@
+import React, { useEffect } from 'react'
+import { useAppContext } from '../../app-context'
+import { useListenEvent, EventType } from '../../app-events'
+import { useForceUpdate } from '../../util/react-util'
+import { ThreeBarsIcon } from '../../icons/three-bars'
+import { getRenderCoords } from '../../util/RenderUtil'
+import { Vector } from '../../playback/Vector'
+import Match from '../../playback/Match'
+import { CurrentMap } from '../../playback/Map'
+import { Body } from '../../playback/Bodies'
+import { Vec } from 'battlecode-schema/js/battlecode/schema'
+
+type OverlayProps = {
+    match: Match
+    overlayCanvas: HTMLCanvasElement
+    selectedBodyID: number | undefined
+    hoveredBodyID: number | undefined
+    hoveredTile: Vector | undefined
+    wrapperRef: HTMLDivElement
+}
+
+export const Overlay = ({
+    match,
+    overlayCanvas,
+    selectedBodyID,
+    hoveredBodyID,
+    hoveredTile,
+    wrapperRef
+}: OverlayProps) => {
+    const appContext = useAppContext()
+    const forceUpdate = useForceUpdate()
+    useListenEvent(EventType.NEW_TURN, forceUpdate) // update tooltip content
+
+    const selectedBody = selectedBodyID !== undefined ? match.currentTurn.bodies.bodies.get(selectedBodyID) : undefined
+    const hoveredBody = hoveredBodyID !== undefined ? match.currentTurn.bodies.bodies.get(hoveredBodyID) : undefined
+    const map = match.currentTurn.map
+
+    const wrapperRect = wrapperRef.getBoundingClientRect()
+
+    const floatingTooltipContent = hoveredBody
+        ? hoveredBody.onHoverInfo()
+        : hoveredTile
+          ? map.getTooltipInfo(hoveredTile, match!)
+          : []
+    const floatingTooltipFocus = hoveredBody ? hoveredBody.pos : hoveredTile
+    const showFloatingTooltip = !!(
+        ((hoveredBody && hoveredBody != selectedBody) || hoveredTile) &&
+        floatingTooltipFocus &&
+        floatingTooltipContent.length > 0
+    )
+
+    return (
+        <>
+            <div style={{ WebkitUserSelect: 'none', userSelect: 'none' }}>
+                {showFloatingTooltip && (
+                    <FloatingTooltip
+                        mapPosition={floatingTooltipFocus}
+                        wrapperRect={wrapperRect}
+                        mapRect={overlayCanvas.getBoundingClientRect()}
+                        map={map}
+                    >
+                        {floatingTooltipContent.map((v, i) => (
+                            <p key={i}>{v}</p>
+                        ))}
+                    </FloatingTooltip>
+                )}
+
+                <DraggableTooltip
+                    areaWidth={wrapperRect.width}
+                    areaHeight={wrapperRect.height}
+                    selectedBody={selectedBody}
+                />
+
+                {appContext.state.config.showMapXY && hoveredTile && (
+                    <div className="absolute right-[5px] top-[5px] bg-black/70 z-20 text-white p-2 rounded-md text-xs opacity-50 pointer-events-none">
+                        {`(X: ${hoveredTile.x}, Y: ${hoveredTile.y})`}
+                    </div>
+                )}
+            </div>
+            {hoveredTile && (
+                <HighlightedSquare
+                    hoveredTile={hoveredTile}
+                    map={match.currentTurn.map}
+                    wrapperRef={wrapperRef}
+                    overlayCanvasRef={overlayCanvas}
+                />
+            )}
+        </>
+    )
+}
+
+interface FloatingTooltipProps {
+    children: React.ReactNode
+    mapPosition: Vector
+    wrapperRect: DOMRect
+    mapRect: DOMRect
+    map: CurrentMap
+}
+
+const FloatingTooltip = ({ children, mapPosition, wrapperRect, mapRect, map }: FloatingTooltipProps) => {
+    const tooltipRef = React.useRef<HTMLDivElement>(null)
+    const position = getRenderCoords(mapPosition.x, mapPosition.y, map.dimension, true)
+
+    const [tooltipSize, setTooltipSize] = React.useState<Vector | undefined>(undefined)
+    useEffect(() => {
+        const observer = new ResizeObserver((entries) => {
+            if (entries[0]) {
+                const borderBox = entries[0].borderBoxSize[0]
+                setTooltipSize({ x: borderBox.inlineSize, y: borderBox.blockSize })
+            }
+        })
+        if (tooltipRef.current) observer.observe(tooltipRef.current)
+        return () => {
+            if (tooltipRef.current) observer.unobserve(tooltipRef.current)
+        }
+    })
+
+    const getTooltipStyle = () => {
+        if (!tooltipSize) return {}
+        
+        const tileWidth = mapRect.width / map.width
+        const tileHeight = mapRect.height / map.height
+        const mapLeft = mapRect.left - wrapperRect.left
+        const mapTop = mapRect.top - wrapperRect.top
+
+        let tooltipStyle: React.CSSProperties = {}
+
+        const distanceFromBotCenterX = 0.75 * tileWidth
+        const distanceFromBotCenterY = 0.75 * tileHeight
+        const clearanceLeft = mapLeft + position.x * tileWidth - distanceFromBotCenterX
+        const clearanceRight = wrapperRect.width - clearanceLeft - 2 * distanceFromBotCenterX
+        const clearanceTop = mapTop + position.y * tileHeight - distanceFromBotCenterY
+
+        if (clearanceTop > tooltipSize.y) {
+            tooltipStyle.top = mapTop + position.y * tileHeight - tooltipSize.y - distanceFromBotCenterY + 'px'
+        } else {
+            tooltipStyle.top = mapTop + position.y * tileHeight + distanceFromBotCenterY + 'px'
+        }
+        if (clearanceLeft < tooltipSize.x / 2) {
+            tooltipStyle.left = mapLeft + position.x * tileWidth + distanceFromBotCenterX + 'px'
+        } else if (clearanceRight < tooltipSize.x / 2) {
+            tooltipStyle.left = mapLeft + position.x * tileWidth - tooltipSize.x - distanceFromBotCenterX + 'px'
+        } else {
+            tooltipStyle.left = mapLeft + position.x * tileWidth - tooltipSize.x / 2 + 'px'
+        }
+
+        return tooltipStyle
+    }
+
+    return (
+        <div
+            className="absolute bg-black/70 z-20 text-white p-2 rounded-md text-xs"
+            style={{ ...getTooltipStyle(), visibility: tooltipSize ? 'visible' : 'hidden' }}
+            ref={tooltipRef}
+        >
+            {children}
+        </div>
+    )
+}
+interface DraggableTooltipProps {
+    areaWidth: number
+    areaHeight: number
+    selectedBody?: Body
+}
+const DraggableTooltip = ({ areaWidth, areaHeight, selectedBody }: DraggableTooltipProps) => {
+    return (
+        <Draggable width={areaWidth} height={areaHeight} margin={0}>
+            {selectedBody && (
+                <div className="bg-black/90 z-20 text-white p-2 rounded-md text-xs cursor-pointer relative">
+                    {selectedBody.onHoverInfo().map((v, i) => (
+                        <p key={i}>{v}</p>
+                    ))}
+                    <div className="absolute top-0 right-0" style={{ transform: 'scaleX(0.57) scaleY(0.73)' }}>
+                        <ThreeBarsIcon />
+                    </div>
+                </div>
+            )}
+        </Draggable>
+    )
+}
+
+interface DraggableProps {
+    children: React.ReactNode
+    width: number
+    height: number
+    margin?: number
+}
+
+const Draggable = ({ children, width, height, margin = 0 }: DraggableProps) => {
+    const [dragging, setDragging] = React.useState(false)
+    const [pos, setPos] = React.useState({ x: 20, y: 20 })
+    const [offset, setOffset] = React.useState({ x: 0, y: 0 })
+    const ref = React.useRef<HTMLDivElement>(null)
+
+    const mouseDown = (e: React.MouseEvent) => {
+        setDragging(true)
+        setOffset({ x: e.clientX - pos.x, y: e.clientY - pos.y })
+    }
+
+    const mouseUp = () => {
+        setDragging(false)
+    }
+
+    const mouseMove = (e: React.MouseEvent) => {
+        if (dragging && ref.current) {
+            const targetX = e.clientX - offset.x
+            const targetY = e.clientY - offset.y
+            const realX = Math.min(Math.max(targetX, margin), width - ref.current.clientWidth - margin)
+            const realY = Math.min(Math.max(targetY, margin), height - ref.current.clientHeight - margin)
+            setPos({ x: realX, y: realY })
+        }
+    }
+
+    return (
+        <div
+            ref={ref}
+            onMouseDown={mouseDown}
+            onMouseUp={mouseUp}
+            onMouseLeave={mouseUp}
+            onMouseEnter={(e) => {
+                if (e.buttons === 1) mouseDown(e)
+            }}
+            onMouseMove={mouseMove}
+            className="absolute z-20"
+            style={{
+                left: pos.x + 'px',
+                top: pos.y + 'px'
+            }}
+        >
+            {children}
+        </div>
+    )
+}
+
+interface HighlightedSquareProps {
+    overlayCanvasRef: HTMLCanvasElement
+    wrapperRef: HTMLDivElement
+    map: CurrentMap
+    hoveredTile: Vector
+}
+const HighlightedSquare: React.FC<HighlightedSquareProps> = ({ overlayCanvasRef, wrapperRef, map, hoveredTile }) => {
+    const overlayCanvasRect = overlayCanvasRef.getBoundingClientRect()
+    const wrapperRect = wrapperRef.getBoundingClientRect()
+    const mapLeft = overlayCanvasRect.left - wrapperRect.left
+    const mapTop = overlayCanvasRect.top - wrapperRect.top
+    const tileWidth = overlayCanvasRect.width / map.width
+    const tileHeight = overlayCanvasRect.height / map.height
+    const tileLeft = mapLeft + tileWidth * hoveredTile.x
+    const tileTop = mapTop + tileHeight * (map.height - hoveredTile.y - 1)
+    return (
+        <div
+            className="absolute border-2 border-black/70 z-10 cursor-pointer"
+            style={{
+                left: tileLeft + 'px',
+                top: tileTop + 'px',
+                width: overlayCanvasRect.width / map.width + 'px',
+                height: overlayCanvasRect.height / map.height + 'px',
+                pointerEvents: 'none'
+            }}
+        />
+    )
+}
diff --git a/client/src/components/game/tooltip.tsx b/client/src/components/game/tooltip.tsx
deleted file mode 100644
index 726fc6c7..00000000
--- a/client/src/components/game/tooltip.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import React, { MutableRefObject, useEffect } from 'react'
-import { useAppContext } from '../../app-context'
-import { useListenEvent, EventType } from '../../app-events'
-import { useForceUpdate } from '../../util/react-util'
-import { ThreeBarsIcon } from '../../icons/three-bars'
-import { getRenderCoords } from '../../util/RenderUtil'
-import { Vector } from '../../playback/Vector'
-
-type TooltipProps = {
-    overlayCanvas: HTMLCanvasElement | null
-    selectedBodyID: number | undefined
-    hoveredBodyID: number | undefined
-    hoveredSquare: Vector | undefined
-    selectedSquare: Vector | undefined
-    wrapperRef: HTMLDivElement | null
-}
-
-export const Tooltip = ({
-    overlayCanvas,
-    selectedBodyID,
-    hoveredBodyID,
-    hoveredSquare,
-    selectedSquare,
-    wrapperRef
-}: TooltipProps) => {
-    const appContext = useAppContext()
-    const forceUpdate = useForceUpdate()
-    useListenEvent(EventType.TURN_PROGRESS, forceUpdate)
-    useListenEvent(EventType.INITIAL_RENDER, forceUpdate)
-
-    const selectedBody =
-        selectedBodyID !== undefined
-            ? appContext.state.activeMatch?.currentTurn.bodies.bodies.get(selectedBodyID)
-            : undefined
-    const hoveredBody =
-        hoveredBodyID !== undefined
-            ? appContext.state.activeMatch?.currentTurn.bodies.bodies.get(hoveredBodyID)
-            : undefined
-
-    const tooltipRef = React.useRef<HTMLDivElement>(null)
-    const [tooltipSize, setTooltipSize] = React.useState({ width: 0, height: 0 })
-    useEffect(() => {
-        const observer = new ResizeObserver((entries) => {
-            if (entries[0]) {
-                const borderBox = entries[0].borderBoxSize[0]
-                setTooltipSize({ width: borderBox.inlineSize, height: borderBox.blockSize })
-            }
-        })
-        if (tooltipRef.current) observer.observe(tooltipRef.current)
-        return () => {
-            if (tooltipRef.current) observer.unobserve(tooltipRef.current)
-        }
-    }, [hoveredBody, hoveredSquare])
-
-    const map = appContext.state.activeMatch?.currentTurn.map
-    if (!overlayCanvas || !wrapperRef || !map) return <></>
-
-    const wrapperRect = wrapperRef.getBoundingClientRect()
-
-    const getTooltipStyle = () => {
-        const overlayCanvasRect = overlayCanvas.getBoundingClientRect()
-        const tileWidth = overlayCanvasRect.width / map.width
-        const tileHeight = overlayCanvasRect.height / map.height
-        const mapLeft = overlayCanvasRect.left - wrapperRect.left
-        const mapTop = overlayCanvasRect.top - wrapperRect.top
-
-        let tooltipStyle: React.CSSProperties = {}
-
-        if (!hoveredBody && !hoveredSquare) return tooltipStyle
-
-        let tipPos: Vector
-        if (hoveredBody) {
-            tipPos = getRenderCoords(hoveredBody.pos.x, hoveredBody.pos.y, map.dimension, true)
-        } else {
-            tipPos = getRenderCoords(hoveredSquare!.x, hoveredSquare!.y, map.dimension, true)
-        }
-        const distanceFromBotCenterX = 0.75 * tileWidth
-        const distanceFromBotCenterY = 0.75 * tileHeight
-        const clearanceLeft = mapLeft + tipPos.x * tileWidth - distanceFromBotCenterX
-        const clearanceRight = wrapperRect.width - clearanceLeft - 2 * distanceFromBotCenterX
-        const clearanceTop = mapTop + tipPos.y * tileHeight - distanceFromBotCenterY
-
-        if (clearanceTop > tooltipSize.height) {
-            tooltipStyle.top = mapTop + tipPos.y * tileHeight - tooltipSize.height - distanceFromBotCenterY + 'px'
-        } else {
-            tooltipStyle.top = mapTop + tipPos.y * tileHeight + distanceFromBotCenterY + 'px'
-        }
-        if (clearanceLeft < tooltipSize.width / 2) {
-            tooltipStyle.left = mapLeft + tipPos.x * tileWidth + distanceFromBotCenterX + 'px'
-        } else if (clearanceRight < tooltipSize.width / 2) {
-            tooltipStyle.left = mapLeft + tipPos.x * tileWidth - tooltipSize.width - distanceFromBotCenterX + 'px'
-        } else {
-            tooltipStyle.left = mapLeft + tipPos.x * tileWidth - tooltipSize.width / 2 + 'px'
-        }
-
-        return tooltipStyle
-    }
-
-    let showFloatingTooltip = !!((hoveredBody && hoveredBody != selectedBody) || hoveredSquare)
-    const tooltipContent = hoveredBody
-        ? hoveredBody.onHoverInfo()
-        : hoveredSquare
-        ? map.getTooltipInfo(hoveredSquare, appContext.state.activeMatch!)
-        : []
-
-    if (tooltipContent.length === 0) showFloatingTooltip = false
-
-    // Check for the default empty size and don't show before the resize observer
-    // has updated
-    if (tooltipSize.width == 16 || tooltipSize.height == 16) showFloatingTooltip = false
-
-    return (
-        <div style={{ WebkitUserSelect: 'none', userSelect: 'none' }}>
-            <div
-                className="absolute bg-black/70 z-20 text-white p-2 rounded-md text-xs"
-                style={{ ...getTooltipStyle(), visibility: showFloatingTooltip ? 'visible' : 'hidden' }}
-                ref={tooltipRef}
-            >
-                {tooltipContent.map((v, i) => (
-                    <p key={i}>{v}</p>
-                ))}
-            </div>
-
-            <Draggable width={wrapperRect.width} height={wrapperRect.height}>
-                {selectedBody && (
-                    <div className="bg-black/90 z-20 text-white p-2 rounded-md text-xs cursor-pointer relative">
-                        {selectedBody.onHoverInfo().map((v, i) => (
-                            <p key={i}>{v}</p>
-                        ))}
-                        <div className="absolute top-0 right-0" style={{ transform: 'scaleX(0.57) scaleY(0.73)' }}>
-                            <ThreeBarsIcon />
-                        </div>
-                    </div>
-                )}
-            </Draggable>
-
-            {appContext.state.config.showMapXY && hoveredSquare && (
-                <div className="absolute right-[5px] top-[5px] bg-black/70 z-20 text-white p-2 rounded-md text-xs opacity-50 pointer-events-none">
-                    {`(X: ${hoveredSquare.x}, Y: ${hoveredSquare.y})`}
-                </div>
-            )}
-        </div>
-    )
-}
-
-interface DraggableProps {
-    children: React.ReactNode
-    width: number
-    height: number
-    margin?: number
-}
-
-const Draggable = ({ children, width, height, margin = 0 }: DraggableProps) => {
-    const [dragging, setDragging] = React.useState(false)
-    const [pos, setPos] = React.useState({ x: 20, y: 20 })
-    const [offset, setOffset] = React.useState({ x: 0, y: 0 })
-    const ref = React.useRef<HTMLDivElement>(null)
-
-    const mouseDown = (e: React.MouseEvent) => {
-        setDragging(true)
-        setOffset({ x: e.clientX - pos.x, y: e.clientY - pos.y })
-    }
-
-    const mouseUp = () => {
-        setDragging(false)
-    }
-
-    const mouseMove = (e: React.MouseEvent) => {
-        if (dragging && ref.current) {
-            const targetX = e.clientX - offset.x
-            const targetY = e.clientY - offset.y
-            const realX = Math.min(Math.max(targetX, margin), width - ref.current.clientWidth - margin)
-            const realY = Math.min(Math.max(targetY, margin), height - ref.current.clientHeight - margin)
-            setPos({ x: realX, y: realY })
-        }
-    }
-
-    return (
-        <div
-            ref={ref}
-            onMouseDown={mouseDown}
-            onMouseUp={mouseUp}
-            onMouseLeave={mouseUp}
-            onMouseEnter={(e) => {
-                if (e.buttons === 1) mouseDown(e)
-            }}
-            onMouseMove={mouseMove}
-            className="absolute z-20"
-            style={{
-                left: pos.x + 'px',
-                top: pos.y + 'px'
-            }}
-        >
-            {children}
-        </div>
-    )
-}
diff --git a/client/src/components/sidebar/game/game.tsx b/client/src/components/sidebar/game/game.tsx
index 35517a9f..11d6cd3d 100644
--- a/client/src/components/sidebar/game/game.tsx
+++ b/client/src/components/sidebar/game/game.tsx
@@ -26,7 +26,7 @@ export const GamePage: React.FC<Props> = React.memo((props) => {
     const [showStats, setShowStats] = useSearchParamBool('showStats', true)
 
     const forceUpdate = useForceUpdate()
-    useListenEvent(EventType.TURN_PROGRESS, forceUpdate)
+    useListenEvent(EventType.NEW_TURN, forceUpdate)
 
     if (!props.open) return null
 
diff --git a/client/src/components/sidebar/game/histogram.tsx b/client/src/components/sidebar/game/histogram.tsx
index ea192586..d5b4402d 100644
--- a/client/src/components/sidebar/game/histogram.tsx
+++ b/client/src/components/sidebar/game/histogram.tsx
@@ -33,7 +33,7 @@ interface SpecialtyHistogramProps {
 export const SpecialtyHistogram: React.FC<SpecialtyHistogramProps> = (props) => {
     const appContext = useAppContext()
     const forceUpdate = useForceUpdate()
-    useListenEvent(EventType.TURN_PROGRESS, () => {
+    useListenEvent(EventType.NEW_TURN, () => {
         if (props.active) forceUpdate()
     })
 
diff --git a/client/src/components/sidebar/game/resource-graph.tsx b/client/src/components/sidebar/game/resource-graph.tsx
index d0e777c2..11d02562 100644
--- a/client/src/components/sidebar/game/resource-graph.tsx
+++ b/client/src/components/sidebar/game/resource-graph.tsx
@@ -41,7 +41,7 @@ export const ResourceGraph: React.FC<Props> = (props: Props) => {
     const appContext = useAppContext()
     const forceUpdate = useForceUpdate()
 
-    useListenEvent(EventType.TURN_PROGRESS, () => {
+    useListenEvent(EventType.NEW_TURN, () => {
         if (props.active) forceUpdate()
     })
 
diff --git a/client/src/components/sidebar/game/team-table.tsx b/client/src/components/sidebar/game/team-table.tsx
index 29f59475..87c1fb4a 100644
--- a/client/src/components/sidebar/game/team-table.tsx
+++ b/client/src/components/sidebar/game/team-table.tsx
@@ -32,7 +32,7 @@ interface TeamTableProps {
 export const TeamTable: React.FC<TeamTableProps> = (props: TeamTableProps) => {
     const context = useAppContext()
     const forceUpdate = useForceUpdate()
-    useListenEvent(EventType.TURN_PROGRESS, forceUpdate)
+    useListenEvent(EventType.NEW_TURN, forceUpdate)
 
     const match = context.state.activeMatch
     const teamStat = match?.currentTurn?.stat.getTeamStat(match.game.teams[props.teamIdx])
diff --git a/client/src/components/sidebar/map-editor/map-editor.tsx b/client/src/components/sidebar/map-editor/map-editor.tsx
index 2f6d9d70..c5874b7e 100644
--- a/client/src/components/sidebar/map-editor/map-editor.tsx
+++ b/client/src/components/sidebar/map-editor/map-editor.tsx
@@ -51,7 +51,7 @@ export const MapEditorPage: React.FC<Props> = (props) => {
         if (!openBrush) return
 
         openBrush.apply(point.x, point.y, openBrush.fields)
-        publishEvent(EventType.INITIAL_RENDER, {})
+        publishEvent(EventType.MAP_RENDER)
         setCleared(mapEmpty())
     }
 
diff --git a/client/src/components/sidebar/runner/websocket.ts b/client/src/components/sidebar/runner/websocket.ts
index 461dae9c..aed19b32 100644
--- a/client/src/components/sidebar/runner/websocket.ts
+++ b/client/src/components/sidebar/runner/websocket.ts
@@ -67,7 +67,7 @@ export default class WebSocketListener {
                 this.lastSetTurn = match.currentTurn.turnNumber
             } else {
                 // Publish anyways so the control bar updates
-                publishEvent(EventType.TURN_PROGRESS, {})
+                publishEvent(EventType.NEW_TURN, {})
             }
         }
 
@@ -110,7 +110,7 @@ export default class WebSocketListener {
                 break
             }
             case schema.Event.GameFooter: {
-                publishEvent(EventType.TURN_PROGRESS, {})
+                publishEvent(EventType.NEW_TURN, {})
                 this.onGameComplete(this.activeGame!)
                 this.reset()
 
diff --git a/client/src/playback/Match.ts b/client/src/playback/Match.ts
index d1c3f8b1..bf9b2b18 100644
--- a/client/src/playback/Match.ts
+++ b/client/src/playback/Match.ts
@@ -213,7 +213,7 @@ export default class Match {
         }
 
         this.currentTurn = updatingTurn
-        publishEvent(EventType.TURN_PROGRESS, {})
+        publishEvent(EventType.NEW_TURN, {})
         if (rerender) this.rerender()
     }
 }

From f0ed7a31e6437c50f02417b7fac4e1a5f9ac9a16 Mon Sep 17 00:00:00 2001
From: Aidan Blum Levine <aidanblumlevine@gmail.com>
Date: Wed, 10 Apr 2024 16:47:45 -0400
Subject: [PATCH 3/5] Move files around

---
 client/src/app-context.tsx                     | 10 +++-------
 .../components/controls-bar/controls-bar.tsx   |  2 +-
 client/src/components/game/GameController.ts   |  2 +-
 client/src/components/game/game-renderer.tsx   |  8 ++++----
 client/src/components/game/overlay.tsx         | 10 +++++-----
 .../game/tournament-renderer}/Tournament.ts    |  0
 .../tournament-renderer/tournament-game.tsx    |  4 ++--
 .../tournament-renderer.tsx                    |  2 +-
 client/src/components/sidebar/help/help.tsx    |  2 +-
 .../sidebar/map-editor/MapEditorBrush.ts       |  2 +-
 .../sidebar/map-editor/map-editor-field.tsx    |  2 +-
 .../sidebar/map-editor/map-editor.tsx          | 12 ++++++------
 .../components/sidebar/queue/queue-game.tsx    |  4 ++--
 client/src/components/sidebar/queue/queue.tsx  |  4 ++--
 .../src/components/sidebar/runner/scaffold.ts  |  6 +++---
 .../src/components/sidebar/runner/websocket.ts |  4 ++--
 client/src/components/sidebar/sidebar.tsx      | 10 +++++-----
 .../{tournament.tsx => tournament-page.tsx}    |  2 +-
 .../src/components/sidebar/update-warning.tsx  |  2 +-
 .../src/{playback => current-game}/Actions.ts  |  6 +++---
 .../src/{playback => current-game}/Bodies.ts   |  8 ++++----
 .../src/{playback => current-game}/Brushes.ts  |  0
 .../Constants.ts}                              |  0
 client/src/{playback => current-game}/Game.ts  |  2 +-
 client/src/{playback => current-game}/Map.ts   | 10 +++++-----
 .../MapGenerator.ts                            | 14 +++++++-------
 client/src/{playback => current-game}/Match.ts |  0
 client/src/{playback => current-game}/Turn.ts  |  0
 .../src/{playback => current-game}/TurnStat.ts |  0
 .../sidebar-game-tab}/game.tsx                 | 18 +++++++++---------
 .../sidebar-game-tab/graphs}/d3-histogram.tsx  |  2 +-
 .../sidebar-game-tab/graphs}/d3-line-chart.tsx |  2 +-
 .../graphs}/quick-histogram.tsx                |  0
 .../graphs}/quick-line-chart.tsx               |  2 +-
 .../sidebar-game-tab}/histogram.tsx            | 10 +++++-----
 .../sidebar-game-tab}/resource-graph.tsx       |  8 ++++----
 .../sidebar-game-tab}/team-table.tsx           | 16 ++++++++--------
 client/src/pages/main-page.tsx                 |  2 +-
 .../util/{ImageLoader.ts => image-loader.ts}   |  0
 .../src/util/{RenderUtil.ts => render-util.ts} | 10 +++++-----
 .../SchemaHelpers.ts => util/schema-util.ts}   |  2 +-
 .../src/{playback/Vector.ts => util/vector.ts} |  0
 42 files changed, 98 insertions(+), 102 deletions(-)
 rename client/src/{playback => components/game/tournament-renderer}/Tournament.ts (100%)
 rename client/src/components/sidebar/tournament/{tournament.tsx => tournament-page.tsx} (98%)
 rename client/src/{playback => current-game}/Actions.ts (98%)
 rename client/src/{playback => current-game}/Bodies.ts (99%)
 rename client/src/{playback => current-game}/Brushes.ts (100%)
 rename client/src/{constants.ts => current-game/Constants.ts} (100%)
 rename client/src/{playback => current-game}/Game.ts (99%)
 rename client/src/{playback => current-game}/Map.ts (98%)
 rename client/src/{components/sidebar/map-editor => current-game}/MapGenerator.ts (94%)
 rename client/src/{playback => current-game}/Match.ts (100%)
 rename client/src/{playback => current-game}/Turn.ts (100%)
 rename client/src/{playback => current-game}/TurnStat.ts (100%)
 rename client/src/{components/sidebar/game => current-game/sidebar-game-tab}/game.tsx (91%)
 rename client/src/{components/sidebar/game => current-game/sidebar-game-tab/graphs}/d3-histogram.tsx (97%)
 rename client/src/{components/sidebar/game => current-game/sidebar-game-tab/graphs}/d3-line-chart.tsx (98%)
 rename client/src/{components/sidebar/game => current-game/sidebar-game-tab/graphs}/quick-histogram.tsx (100%)
 rename client/src/{components/sidebar/game => current-game/sidebar-game-tab/graphs}/quick-line-chart.tsx (97%)
 rename client/src/{components/sidebar/game => current-game/sidebar-game-tab}/histogram.tsx (89%)
 rename client/src/{components/sidebar/game => current-game/sidebar-game-tab}/resource-graph.tsx (87%)
 rename client/src/{components/sidebar/game => current-game/sidebar-game-tab}/team-table.tsx (93%)
 rename client/src/util/{ImageLoader.ts => image-loader.ts} (100%)
 rename client/src/util/{RenderUtil.ts => render-util.ts} (97%)
 rename client/src/{playback/SchemaHelpers.ts => util/schema-util.ts} (95%)
 rename client/src/{playback/Vector.ts => util/vector.ts} (100%)

diff --git a/client/src/app-context.tsx b/client/src/app-context.tsx
index eebe5817..62071b4b 100644
--- a/client/src/app-context.tsx
+++ b/client/src/app-context.tsx
@@ -1,8 +1,8 @@
 import React from 'react'
-import Game from './playback/Game'
-import Match from './playback/Match'
-import Tournament, { DEFAULT_TOURNAMENT_STATE, TournamentState } from './playback/Tournament'
+import Game from './current-game/Game'
+import Match from './current-game/Match'
 import { ClientConfig, getDefaultConfig } from './client-config'
+import Tournament, { DEFAULT_TOURNAMENT_STATE, TournamentState } from './components/game/tournament-renderer/Tournament'
 
 export interface AppState {
     queue: Game[]
@@ -11,8 +11,6 @@ export interface AppState {
     tournament: Tournament | undefined
     tournamentState: TournamentState
     loadingRemoteContent: string
-    // updatesPerSecond: number
-    // paused: boolean
     disableHotkeys: boolean
     config: ClientConfig
 }
@@ -24,8 +22,6 @@ const DEFAULT_APP_STATE: AppState = {
     tournament: undefined,
     tournamentState: DEFAULT_TOURNAMENT_STATE,
     loadingRemoteContent: '',
-    // updatesPerSecond: 1,
-    // paused: true,
     disableHotkeys: false,
     config: getDefaultConfig()
 }
diff --git a/client/src/components/controls-bar/controls-bar.tsx b/client/src/components/controls-bar/controls-bar.tsx
index 730c4fc2..b52e59c6 100644
--- a/client/src/components/controls-bar/controls-bar.tsx
+++ b/client/src/components/controls-bar/controls-bar.tsx
@@ -7,7 +7,7 @@ import { ControlsBarTimeline } from './controls-bar-timeline'
 import { EventType, useListenEvent } from '../../app-events'
 import { useForceUpdate } from '../../util/react-util'
 import Tooltip from '../tooltip'
-import Match from '../../playback/Match'
+import Match from '../../current-game/Match'
 
 type ControlsBarProps = {
     match: Match | undefined
diff --git a/client/src/components/game/GameController.ts b/client/src/components/game/GameController.ts
index ef0cfe5e..65f00797 100644
--- a/client/src/components/game/GameController.ts
+++ b/client/src/components/game/GameController.ts
@@ -1,5 +1,5 @@
 import { useEffect, useRef, useState } from 'react'
-import Match from '../../playback/Match'
+import Match from '../../current-game/Match'
 
 const SIMULATION_UPDATE_INTERVAL_MS = 17 // About 60 fps
 
diff --git a/client/src/components/game/game-renderer.tsx b/client/src/components/game/game-renderer.tsx
index 97793993..64e40497 100644
--- a/client/src/components/game/game-renderer.tsx
+++ b/client/src/components/game/game-renderer.tsx
@@ -1,11 +1,11 @@
 import React, { MutableRefObject, useEffect, useRef, useState } from 'react'
 import { useAppContext } from '../../app-context'
-import { Vector } from '../../playback/Vector'
+import { Vector } from '../../util/vector'
 import { EventType, publishEvent, useListenEvent } from '../../app-events'
 import { Overlay } from './overlay'
-import { TILE_RESOLUTION } from '../../constants'
-import { CurrentMap } from '../../playback/Map'
-import Match from '../../playback/Match'
+import { TILE_RESOLUTION } from '../../current-game/Constants'
+import { CurrentMap } from '../../current-game/Map'
+import Match from '../../current-game/Match'
 import { ClientConfig } from '../../client-config'
 
 export const GameRenderer: React.FC = () => {
diff --git a/client/src/components/game/overlay.tsx b/client/src/components/game/overlay.tsx
index d35413c0..03a4df0f 100644
--- a/client/src/components/game/overlay.tsx
+++ b/client/src/components/game/overlay.tsx
@@ -3,11 +3,11 @@ import { useAppContext } from '../../app-context'
 import { useListenEvent, EventType } from '../../app-events'
 import { useForceUpdate } from '../../util/react-util'
 import { ThreeBarsIcon } from '../../icons/three-bars'
-import { getRenderCoords } from '../../util/RenderUtil'
-import { Vector } from '../../playback/Vector'
-import Match from '../../playback/Match'
-import { CurrentMap } from '../../playback/Map'
-import { Body } from '../../playback/Bodies'
+import { getRenderCoords } from '../../util/render-util'
+import { Vector } from '../../util/vector'
+import Match from '../../current-game/Match'
+import { CurrentMap } from '../../current-game/Map'
+import { Body } from '../../current-game/Bodies'
 import { Vec } from 'battlecode-schema/js/battlecode/schema'
 
 type OverlayProps = {
diff --git a/client/src/playback/Tournament.ts b/client/src/components/game/tournament-renderer/Tournament.ts
similarity index 100%
rename from client/src/playback/Tournament.ts
rename to client/src/components/game/tournament-renderer/Tournament.ts
diff --git a/client/src/components/game/tournament-renderer/tournament-game.tsx b/client/src/components/game/tournament-renderer/tournament-game.tsx
index e978eb49..635d2f3e 100644
--- a/client/src/components/game/tournament-renderer/tournament-game.tsx
+++ b/client/src/components/game/tournament-renderer/tournament-game.tsx
@@ -1,11 +1,11 @@
 import React from 'react'
 import { useAppContext } from '../../../app-context'
-import { TournamentGame } from '../../../playback/Tournament'
-import Game from '../../../playback/Game'
+import Game from '../../../current-game/Game'
 import { PageType, usePage } from '../../../app-search-params'
 import { Pressable } from 'react-zoomable-ui'
 import { Crown } from '../../../icons/crown'
 import Tooltip from '../../tooltip'
+import { TournamentGame } from './Tournament';
 
 interface Props {
     game: TournamentGame
diff --git a/client/src/components/game/tournament-renderer/tournament-renderer.tsx b/client/src/components/game/tournament-renderer/tournament-renderer.tsx
index c29e647b..0a6de02d 100644
--- a/client/src/components/game/tournament-renderer/tournament-renderer.tsx
+++ b/client/src/components/game/tournament-renderer/tournament-renderer.tsx
@@ -1,9 +1,9 @@
 import React, { useEffect, useRef, useState } from 'react'
 import { useAppContext } from '../../../app-context'
 import { TournamentGameElement } from './tournament-game'
-import Tournament, { TournamentGame, TournamentState } from '../../../playback/Tournament'
 import { Space } from 'react-zoomable-ui'
 import { useSearchParamNumber } from '../../../app-search-params'
+import Tournament, { TournamentState, TournamentGame } from './Tournament';
 
 export const TournamentRenderer: React.FC = () => {
     const appContext = useAppContext()
diff --git a/client/src/components/sidebar/help/help.tsx b/client/src/components/sidebar/help/help.tsx
index 1dc83b53..aa51a083 100644
--- a/client/src/components/sidebar/help/help.tsx
+++ b/client/src/components/sidebar/help/help.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import { SectionHeader } from '../../section-header'
-import { BATTLECODE_YEAR } from '../../../constants'
+import { BATTLECODE_YEAR } from '../../../current-game/Constants'
 
 enum TabType {
     NONE = '',
diff --git a/client/src/components/sidebar/map-editor/MapEditorBrush.ts b/client/src/components/sidebar/map-editor/MapEditorBrush.ts
index 5f8c05fb..d7962590 100644
--- a/client/src/components/sidebar/map-editor/MapEditorBrush.ts
+++ b/client/src/components/sidebar/map-editor/MapEditorBrush.ts
@@ -1,4 +1,4 @@
-import { StaticMap, CurrentMap } from '../../../playback/Map'
+import { StaticMap, CurrentMap } from '../../../current-game/Map'
 
 export abstract class MapEditorBrush {
     abstract name: string
diff --git a/client/src/components/sidebar/map-editor/map-editor-field.tsx b/client/src/components/sidebar/map-editor/map-editor-field.tsx
index 953b5cc9..e0aa4342 100644
--- a/client/src/components/sidebar/map-editor/map-editor-field.tsx
+++ b/client/src/components/sidebar/map-editor/map-editor-field.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import { MapEditorBrushField, MapEditorBrushFieldType } from './MapEditorBrush'
-import { TEAM_COLORS, TEAM_COLOR_NAMES } from '../../../constants'
+import { TEAM_COLORS, TEAM_COLOR_NAMES } from '../../../current-game/Constants'
 import { Toggle } from '../../toggle'
 import { Select, NumInput } from '../../forms'
 
diff --git a/client/src/components/sidebar/map-editor/map-editor.tsx b/client/src/components/sidebar/map-editor/map-editor.tsx
index c5874b7e..158b01f0 100644
--- a/client/src/components/sidebar/map-editor/map-editor.tsx
+++ b/client/src/components/sidebar/map-editor/map-editor.tsx
@@ -1,16 +1,16 @@
 import React, { useEffect } from 'react'
-import { CurrentMap, StaticMap } from '../../../playback/Map'
+import { CurrentMap, StaticMap } from '../../../current-game/Map'
 import { MapEditorBrushRow } from './map-editor-brushes'
-import Bodies from '../../../playback/Bodies'
-import Game from '../../../playback/Game'
+import Bodies from '../../../current-game/Bodies'
+import Game from '../../../current-game/Game'
 import { Button, BrightButton, SmallButton } from '../../button'
 import { NumInput, Select } from '../../forms'
 import { useAppContext } from '../../../app-context'
-import Match from '../../../playback/Match'
+import Match from '../../../current-game/Match'
 import { EventType, publishEvent, useListenEvent } from '../../../app-events'
 import { MapEditorBrush } from './MapEditorBrush'
-import { exportMap, loadFileAsMap } from './MapGenerator'
-import { MAP_SIZE_RANGE } from '../../../constants'
+import { exportMap, loadFileAsMap } from '../../../current-game/MapGenerator'
+import { MAP_SIZE_RANGE } from '../../../current-game/Constants'
 import { InputDialog } from '../../input-dialog'
 import { ConfirmDialog } from '../../confirm-dialog'
 
diff --git a/client/src/components/sidebar/queue/queue-game.tsx b/client/src/components/sidebar/queue/queue-game.tsx
index c0710b85..e99876d8 100644
--- a/client/src/components/sidebar/queue/queue-game.tsx
+++ b/client/src/components/sidebar/queue/queue-game.tsx
@@ -1,6 +1,6 @@
 import React, { useState } from 'react'
-import Game from '../../../playback/Game'
-import Match from '../../../playback/Match'
+import Game from '../../../current-game/Game'
+import Match from '../../../current-game/Match'
 import { useAppContext } from '../../../app-context'
 import { IconContext } from 'react-icons'
 import { IoCloseCircle, IoCloseCircleOutline } from 'react-icons/io5'
diff --git a/client/src/components/sidebar/queue/queue.tsx b/client/src/components/sidebar/queue/queue.tsx
index bc172134..243b588a 100644
--- a/client/src/components/sidebar/queue/queue.tsx
+++ b/client/src/components/sidebar/queue/queue.tsx
@@ -1,10 +1,10 @@
 import React from 'react'
 import { useAppContext } from '../../../app-context'
 import { useKeyboard } from '../../../util/keyboard'
-import { BATTLECODE_YEAR } from '../../../constants'
+import { BATTLECODE_YEAR } from '../../../current-game/Constants'
 import { Button } from '../../button'
 import { FiUpload } from 'react-icons/fi'
-import Game from '../../../playback/Game'
+import Game from '../../../current-game/Game'
 import { QueuedGame } from './queue-game'
 
 interface Props {
diff --git a/client/src/components/sidebar/runner/scaffold.ts b/client/src/components/sidebar/runner/scaffold.ts
index df99530b..43490ea4 100644
--- a/client/src/components/sidebar/runner/scaffold.ts
+++ b/client/src/components/sidebar/runner/scaffold.ts
@@ -1,12 +1,12 @@
 import { useEffect, useRef, useState } from 'react'
-import { BATTLECODE_YEAR, ENGINE_BUILTIN_MAP_NAMES } from '../../../constants'
+import { BATTLECODE_YEAR, ENGINE_BUILTIN_MAP_NAMES } from '../../../current-game/Constants'
 import { NativeAPI, nativeAPI } from './native-api-wrapper'
 import { ConsoleLine } from './runner'
 import { useForceUpdate } from '../../../util/react-util'
 import WebSocketListener from './websocket'
 import { useAppContext } from '../../../app-context'
-import Game from '../../../playback/Game'
-import Match from '../../../playback/Match'
+import Game from '../../../current-game/Game'
+import Match from '../../../current-game/Match'
 import { RingBuffer } from '../../../util/ring-buffer'
 
 export type JavaInstall = {
diff --git a/client/src/components/sidebar/runner/websocket.ts b/client/src/components/sidebar/runner/websocket.ts
index aed19b32..78fb4c22 100644
--- a/client/src/components/sidebar/runner/websocket.ts
+++ b/client/src/components/sidebar/runner/websocket.ts
@@ -1,6 +1,6 @@
 import { schema, flatbuffers } from 'battlecode-schema'
-import Game from '../../../playback/Game'
-import Match from '../../../playback/Match'
+import Game from '../../../current-game/Game'
+import Match from '../../../current-game/Match'
 import assert from 'assert'
 import { EventType, publishEvent } from '../../../app-events'
 
diff --git a/client/src/components/sidebar/sidebar.tsx b/client/src/components/sidebar/sidebar.tsx
index 33c07c69..5fcb2ca6 100644
--- a/client/src/components/sidebar/sidebar.tsx
+++ b/client/src/components/sidebar/sidebar.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
-import { BATTLECODE_YEAR, GAME_VERSION } from '../../constants'
+import { BATTLECODE_YEAR, GAME_VERSION } from '../../current-game/Constants'
 import { ThreeBarsIcon } from '../../icons/three-bars'
-import { GamePage } from './game/game'
+import { GamePage } from '../../current-game/sidebar-game-tab/game'
 import { QueuePage } from './queue/queue'
 import { BsChevronLeft } from 'react-icons/bs'
 import { HelpPage } from './help/help'
@@ -11,13 +11,13 @@ import { usePage, PageType, useSearchParamBool, useSearchParamString } from '../
 import { useKeyboard } from '../../util/keyboard'
 import { Scrollbars } from 'react-custom-scrollbars-2'
 import useWindowDimensions from '../../util/window-size'
-import { TournamentPage } from './tournament/tournament'
-import Tournament, { JsonTournamentGame } from '../../playback/Tournament'
 import { useAppContext } from '../../app-context'
 import { useScaffold } from './runner/scaffold'
 import { ConfigPage } from '../../client-config'
 import { UpdateWarning } from './update-warning'
-import Game from '../../playback/Game'
+import Game from '../../current-game/Game'
+import Tournament, { JsonTournamentGame } from '../game/tournament-renderer/Tournament'
+import { TournamentPage } from './tournament/tournament-page';
 
 export const Sidebar: React.FC = () => {
     const { width, height } = useWindowDimensions()
diff --git a/client/src/components/sidebar/tournament/tournament.tsx b/client/src/components/sidebar/tournament/tournament-page.tsx
similarity index 98%
rename from client/src/components/sidebar/tournament/tournament.tsx
rename to client/src/components/sidebar/tournament/tournament-page.tsx
index e23006ce..0cb43549 100644
--- a/client/src/components/sidebar/tournament/tournament.tsx
+++ b/client/src/components/sidebar/tournament/tournament-page.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import { useAppContext } from '../../../app-context'
 import { Button } from '../../button'
 import { FiEye, FiEyeOff, FiUpload } from 'react-icons/fi'
-import Tournament, { JsonTournamentGame } from '../../../playback/Tournament'
+import Tournament, { JsonTournamentGame } from '../../game/tournament-renderer/Tournament'
 import { NumInput } from '../../forms'
 import { BsLock, BsUnlock } from 'react-icons/bs'
 import { useSearchParamBool, useSearchParamNumber } from '../../../app-search-params'
diff --git a/client/src/components/sidebar/update-warning.tsx b/client/src/components/sidebar/update-warning.tsx
index caac34f7..3c4c7f6f 100644
--- a/client/src/components/sidebar/update-warning.tsx
+++ b/client/src/components/sidebar/update-warning.tsx
@@ -1,5 +1,5 @@
 import React, { useEffect } from 'react'
-import { BATTLECODE_YEAR, GAME_VERSION } from '../../constants'
+import { BATTLECODE_YEAR, GAME_VERSION } from '../../current-game/Constants'
 import { nativeAPI } from './runner/native-api-wrapper'
 
 const UPDATE_CHECK_MINUTES = 5
diff --git a/client/src/playback/Actions.ts b/client/src/current-game/Actions.ts
similarity index 98%
rename from client/src/playback/Actions.ts
rename to client/src/current-game/Actions.ts
index ad7d615a..b150423c 100644
--- a/client/src/playback/Actions.ts
+++ b/client/src/current-game/Actions.ts
@@ -1,11 +1,11 @@
 import Turn from './Turn'
 import { schema } from 'battlecode-schema'
 import assert from 'assert'
-import * as renderUtils from '../util/RenderUtil'
-import { vectorAdd, vectorLength, vectorMultiply, vectorSub, vectorMultiplyInPlace, Vector } from './Vector'
+import * as renderUtils from '../util/render-util'
+import { vectorAdd, vectorLength, vectorMultiply, vectorSub, vectorMultiplyInPlace, Vector } from '../util/vector'
 import Match from './Match'
 import { Body } from './Bodies'
-import { ATTACK_COLOR, GRASS_COLOR, HEAL_COLOR, TEAM_COLORS, WATER_COLOR } from '../constants'
+import { ATTACK_COLOR, GRASS_COLOR, HEAL_COLOR, TEAM_COLORS, WATER_COLOR } from './Constants'
 
 export default class Actions {
     actions: Action[] = []
diff --git a/client/src/playback/Bodies.ts b/client/src/current-game/Bodies.ts
similarity index 99%
rename from client/src/playback/Bodies.ts
rename to client/src/current-game/Bodies.ts
index f0c09da9..7304316c 100644
--- a/client/src/playback/Bodies.ts
+++ b/client/src/current-game/Bodies.ts
@@ -3,11 +3,11 @@ import assert from 'assert'
 import Game, { Team } from './Game'
 import Turn from './Turn'
 import TurnStat from './TurnStat'
-import { getImageIfLoaded } from '../util/ImageLoader'
-import * as renderUtils from '../util/RenderUtil'
+import { getImageIfLoaded } from '../util/image-loader'
+import * as renderUtils from '../util/render-util'
 import { MapEditorBrush } from '../components/sidebar/map-editor/MapEditorBrush'
 import { StaticMap } from './Map'
-import { Vector } from './Vector'
+import { Vector } from '../util/vector'
 import {
     ATTACK_COLOR,
     BUILD_COLOR,
@@ -18,7 +18,7 @@ import {
     TOOLTIP_PATH_DECAY_R,
     TOOLTIP_PATH_INIT_R,
     TOOLTIP_PATH_LENGTH
-} from '../constants'
+} from './Constants'
 import Match from './Match'
 import { ClientConfig } from '../client-config'
 
diff --git a/client/src/playback/Brushes.ts b/client/src/current-game/Brushes.ts
similarity index 100%
rename from client/src/playback/Brushes.ts
rename to client/src/current-game/Brushes.ts
diff --git a/client/src/constants.ts b/client/src/current-game/Constants.ts
similarity index 100%
rename from client/src/constants.ts
rename to client/src/current-game/Constants.ts
diff --git a/client/src/playback/Game.ts b/client/src/current-game/Game.ts
similarity index 99%
rename from client/src/playback/Game.ts
rename to client/src/current-game/Game.ts
index 4823ecff..24646ef5 100644
--- a/client/src/playback/Game.ts
+++ b/client/src/current-game/Game.ts
@@ -2,7 +2,7 @@ import Match from './Match'
 import { flatbuffers, schema } from 'battlecode-schema'
 import { ungzip } from 'pako'
 import assert from 'assert'
-import { SPEC_VERSION, TEAM_COLORS, TEAM_COLOR_NAMES } from '../constants'
+import { SPEC_VERSION, TEAM_COLORS, TEAM_COLOR_NAMES } from './Constants'
 import { FakeGameWrapper } from '../components/sidebar/runner/websocket'
 
 let nextID = 0
diff --git a/client/src/playback/Map.ts b/client/src/current-game/Map.ts
similarity index 98%
rename from client/src/playback/Map.ts
rename to client/src/current-game/Map.ts
index e96e397c..b7188717 100644
--- a/client/src/playback/Map.ts
+++ b/client/src/current-game/Map.ts
@@ -1,9 +1,9 @@
 import { flatbuffers, schema } from 'battlecode-schema'
 import assert from 'assert'
-import { Vector } from './Vector'
+import { Vector } from '../util/vector'
 import Match from './Match'
 import { MapEditorBrush, Symmetry } from '../components/sidebar/map-editor/MapEditorBrush'
-import { packVecTable, parseVecTable } from './SchemaHelpers'
+import { packVecTable, parseVecTable } from '../util/schema-util'
 import { DividerBrush, ResourcePileBrush, SpawnZoneBrush, WallsBrush, WaterBrush } from './Brushes'
 import {
     DIVIDER_COLOR,
@@ -13,9 +13,9 @@ import {
     TEAM_COLORS,
     BUILD_NAMES,
     TEAM_COLOR_NAMES
-} from '../constants'
-import * as renderUtils from '../util/RenderUtil'
-import { getImageIfLoaded } from '../util/ImageLoader'
+} from './Constants'
+import * as renderUtils from '../util/render-util'
+import { getImageIfLoaded } from '../util/image-loader'
 import { ClientConfig } from '../client-config'
 
 export type Dimension = {
diff --git a/client/src/components/sidebar/map-editor/MapGenerator.ts b/client/src/current-game/MapGenerator.ts
similarity index 94%
rename from client/src/components/sidebar/map-editor/MapGenerator.ts
rename to client/src/current-game/MapGenerator.ts
index 98d19387..e404cdbc 100644
--- a/client/src/components/sidebar/map-editor/MapGenerator.ts
+++ b/client/src/current-game/MapGenerator.ts
@@ -1,11 +1,11 @@
 import { schema, flatbuffers } from 'battlecode-schema'
-import Game from '../../../playback/Game'
-import Match from '../../../playback/Match'
-import { CurrentMap, StaticMap } from '../../../playback/Map'
-import Turn from '../../../playback/Turn'
-import Bodies from '../../../playback/Bodies'
-import { BATTLECODE_YEAR, DIRECTIONS } from '../../../constants'
-import { nativeAPI } from '../runner/native-api-wrapper'
+import Game from './Game'
+import Match from './Match'
+import { CurrentMap, StaticMap } from './Map'
+import Turn from './Turn'
+import Bodies from './Bodies'
+import { BATTLECODE_YEAR, DIRECTIONS } from './Constants'
+import { nativeAPI } from '../components/sidebar/runner/native-api-wrapper'
 
 export function loadFileAsMap(file: File): Promise<Game> {
     return new Promise((resolve, reject) => {
diff --git a/client/src/playback/Match.ts b/client/src/current-game/Match.ts
similarity index 100%
rename from client/src/playback/Match.ts
rename to client/src/current-game/Match.ts
diff --git a/client/src/playback/Turn.ts b/client/src/current-game/Turn.ts
similarity index 100%
rename from client/src/playback/Turn.ts
rename to client/src/current-game/Turn.ts
diff --git a/client/src/playback/TurnStat.ts b/client/src/current-game/TurnStat.ts
similarity index 100%
rename from client/src/playback/TurnStat.ts
rename to client/src/current-game/TurnStat.ts
diff --git a/client/src/components/sidebar/game/game.tsx b/client/src/current-game/sidebar-game-tab/game.tsx
similarity index 91%
rename from client/src/components/sidebar/game/game.tsx
rename to client/src/current-game/sidebar-game-tab/game.tsx
index 11d6cd3d..adf4ccc7 100644
--- a/client/src/components/sidebar/game/game.tsx
+++ b/client/src/current-game/sidebar-game-tab/game.tsx
@@ -2,16 +2,16 @@ import React from 'react'
 import { TeamTable } from './team-table'
 import { ResourceGraph } from './resource-graph'
 import { SpecialtyHistogram } from './histogram'
-import { useSearchParamBool } from '../../../app-search-params'
-import { useAppContext } from '../../../app-context'
-import { SectionHeader } from '../../section-header'
-import { Crown } from '../../../icons/crown'
+import { useSearchParamBool } from '../../app-search-params'
+import { useAppContext } from '../../app-context'
+import { SectionHeader } from '../../components/section-header'
+import { Crown } from '../../icons/crown'
 import { BiMedal } from 'react-icons/bi'
-import { EventType, useListenEvent } from '../../../app-events'
-import Tooltip from '../../tooltip'
-import { useForceUpdate } from '../../../util/react-util'
-import Match from '../../../playback/Match'
-import { Team } from '../../../playback/Game'
+import { EventType, useListenEvent } from '../../app-events'
+import Tooltip from '../../components/tooltip'
+import { useForceUpdate } from '../../util/react-util'
+import Match from '../Match'
+import { Team } from '../Game'
 
 const NO_GAME_TEAM_NAME = '?????'
 
diff --git a/client/src/components/sidebar/game/d3-histogram.tsx b/client/src/current-game/sidebar-game-tab/graphs/d3-histogram.tsx
similarity index 97%
rename from client/src/components/sidebar/game/d3-histogram.tsx
rename to client/src/current-game/sidebar-game-tab/graphs/d3-histogram.tsx
index 73cadc21..4645bab1 100644
--- a/client/src/components/sidebar/game/d3-histogram.tsx
+++ b/client/src/current-game/sidebar-game-tab/graphs/d3-histogram.tsx
@@ -1,5 +1,5 @@
 import React, { useEffect, useRef } from 'react'
-import { TEAM_WHITE, TEAM_BROWN } from '../../../constants'
+import { TEAM_WHITE, TEAM_BROWN } from '../../Constants'
 import * as d3 from 'd3'
 
 interface HistogramProps {
diff --git a/client/src/components/sidebar/game/d3-line-chart.tsx b/client/src/current-game/sidebar-game-tab/graphs/d3-line-chart.tsx
similarity index 98%
rename from client/src/components/sidebar/game/d3-line-chart.tsx
rename to client/src/current-game/sidebar-game-tab/graphs/d3-line-chart.tsx
index 2f90f41f..93db7a8e 100644
--- a/client/src/components/sidebar/game/d3-line-chart.tsx
+++ b/client/src/current-game/sidebar-game-tab/graphs/d3-line-chart.tsx
@@ -1,5 +1,5 @@
 import React, { useEffect, useRef } from 'react'
-import { TEAM_WHITE, TEAM_BROWN } from '../../../constants'
+import { TEAM_WHITE, TEAM_BROWN } from '../../Constants'
 import * as d3 from 'd3'
 
 export interface LineChartDataPoint {
diff --git a/client/src/components/sidebar/game/quick-histogram.tsx b/client/src/current-game/sidebar-game-tab/graphs/quick-histogram.tsx
similarity index 100%
rename from client/src/components/sidebar/game/quick-histogram.tsx
rename to client/src/current-game/sidebar-game-tab/graphs/quick-histogram.tsx
diff --git a/client/src/components/sidebar/game/quick-line-chart.tsx b/client/src/current-game/sidebar-game-tab/graphs/quick-line-chart.tsx
similarity index 97%
rename from client/src/components/sidebar/game/quick-line-chart.tsx
rename to client/src/current-game/sidebar-game-tab/graphs/quick-line-chart.tsx
index 44b09f54..0c8f6f4f 100644
--- a/client/src/components/sidebar/game/quick-line-chart.tsx
+++ b/client/src/current-game/sidebar-game-tab/graphs/quick-line-chart.tsx
@@ -1,5 +1,5 @@
 import React, { useEffect, useRef } from 'react'
-import { TEAM_WHITE, TEAM_BROWN } from '../../../constants'
+import { TEAM_WHITE, TEAM_BROWN } from '../../Constants'
 import { drawAxes, getAxes, setCanvasResolution } from '../../../util/graph-util'
 
 export interface LineChartDataPoint {
diff --git a/client/src/components/sidebar/game/histogram.tsx b/client/src/current-game/sidebar-game-tab/histogram.tsx
similarity index 89%
rename from client/src/components/sidebar/game/histogram.tsx
rename to client/src/current-game/sidebar-game-tab/histogram.tsx
index d5b4402d..eb09be1e 100644
--- a/client/src/components/sidebar/game/histogram.tsx
+++ b/client/src/current-game/sidebar-game-tab/histogram.tsx
@@ -1,9 +1,9 @@
 import React from 'react'
-import { AppContext, useAppContext } from '../../../app-context'
-import { useListenEvent, EventType } from '../../../app-events'
-import { useForceUpdate } from '../../../util/react-util'
-import { CanvasHistogram } from './quick-histogram'
-import { ATTACK_COLOR, SPECIALTY_COLORS, TEAM_COLORS } from '../../../constants'
+import { AppContext, useAppContext } from '../../app-context'
+import { useListenEvent, EventType } from '../../app-events'
+import { useForceUpdate } from '../../util/react-util'
+import { CanvasHistogram } from './graphs/quick-histogram'
+import { ATTACK_COLOR, SPECIALTY_COLORS, TEAM_COLORS } from '../Constants'
 
 function getChartData(appContext: AppContext): number[][][] {
     const match = appContext.state.activeMatch
diff --git a/client/src/components/sidebar/game/resource-graph.tsx b/client/src/current-game/sidebar-game-tab/resource-graph.tsx
similarity index 87%
rename from client/src/components/sidebar/game/resource-graph.tsx
rename to client/src/current-game/sidebar-game-tab/resource-graph.tsx
index 11d02562..024bad83 100644
--- a/client/src/components/sidebar/game/resource-graph.tsx
+++ b/client/src/current-game/sidebar-game-tab/resource-graph.tsx
@@ -1,8 +1,8 @@
 import React from 'react'
-import { AppContext, useAppContext } from '../../../app-context'
-import { useListenEvent, EventType } from '../../../app-events'
-import { useForceUpdate } from '../../../util/react-util'
-import { D3LineChart, LineChartDataPoint } from './d3-line-chart'
+import { AppContext, useAppContext } from '../../app-context'
+import { useListenEvent, EventType } from '../../app-events'
+import { useForceUpdate } from '../../util/react-util'
+import { D3LineChart, LineChartDataPoint } from './graphs/d3-line-chart'
 import assert from 'assert'
 
 interface Props {
diff --git a/client/src/components/sidebar/game/team-table.tsx b/client/src/current-game/sidebar-game-tab/team-table.tsx
similarity index 93%
rename from client/src/components/sidebar/game/team-table.tsx
rename to client/src/current-game/sidebar-game-tab/team-table.tsx
index 87c1fb4a..f01657f4 100644
--- a/client/src/components/sidebar/game/team-table.tsx
+++ b/client/src/current-game/sidebar-game-tab/team-table.tsx
@@ -1,13 +1,13 @@
 import React, { useEffect } from 'react'
-import { useAppContext } from '../../../app-context'
-import { useForceUpdate } from '../../../util/react-util'
-import { useListenEvent, EventType } from '../../../app-events'
-import { getImageIfLoaded, imageSource, removeTriggerOnImageLoad, triggerOnImageLoad } from '../../../util/ImageLoader'
-import { TEAM_COLOR_NAMES } from '../../../constants'
+import { useAppContext } from '../../app-context'
+import { useForceUpdate } from '../../util/react-util'
+import { useListenEvent, EventType } from '../../app-events'
+import { getImageIfLoaded, imageSource, removeTriggerOnImageLoad, triggerOnImageLoad } from '../../util/image-loader'
+import { TEAM_COLOR_NAMES } from '../Constants'
 import { schema } from 'battlecode-schema'
-import { TeamTurnStat } from '../../../playback/TurnStat'
-import { DoubleChevronUpIcon } from '../../../icons/chevron'
-import { CurrentMap } from '../../../playback/Map'
+import { TeamTurnStat } from '../TurnStat'
+import { DoubleChevronUpIcon } from '../../icons/chevron'
+import { CurrentMap } from '../Map'
 
 interface UnitsIconProps {
     teamIdx: 0 | 1
diff --git a/client/src/pages/main-page.tsx b/client/src/pages/main-page.tsx
index 2f61e002..6525fbb4 100644
--- a/client/src/pages/main-page.tsx
+++ b/client/src/pages/main-page.tsx
@@ -3,7 +3,7 @@ import { AppContextProvider } from '../app-context'
 import { ControlsBar } from '../components/controls-bar/controls-bar'
 import { Sidebar } from '../components/sidebar/sidebar'
 import { GameArea } from '../components/game/game-area'
-import { GAMEAREA_BACKGROUND } from '../constants'
+import { GAMEAREA_BACKGROUND } from '../current-game/Constants'
 
 export const MainPage: React.FC = () => {
     return (
diff --git a/client/src/util/ImageLoader.ts b/client/src/util/image-loader.ts
similarity index 100%
rename from client/src/util/ImageLoader.ts
rename to client/src/util/image-loader.ts
diff --git a/client/src/util/RenderUtil.ts b/client/src/util/render-util.ts
similarity index 97%
rename from client/src/util/RenderUtil.ts
rename to client/src/util/render-util.ts
index fb914aef..acf6ba68 100644
--- a/client/src/util/RenderUtil.ts
+++ b/client/src/util/render-util.ts
@@ -1,8 +1,8 @@
-import * as cst from '../constants'
-import { Team } from '../playback/Game'
-import { CurrentMap, Dimension, StaticMap } from '../playback/Map'
-import { Vector } from '../playback/Vector'
-import { Body } from '../playback/Bodies'
+import * as cst from '../current-game/Constants'
+import { Team } from '../current-game/Game'
+import { CurrentMap, Dimension, StaticMap } from '../current-game/Map'
+import { Vector } from './vector'
+import { Body } from '../current-game/Bodies'
 
 export const getRenderCoords = (cellX: number, cellY: number, dims: Dimension, centered: boolean = false) => {
     const cx = dims.minCorner.x + cellX
diff --git a/client/src/playback/SchemaHelpers.ts b/client/src/util/schema-util.ts
similarity index 95%
rename from client/src/playback/SchemaHelpers.ts
rename to client/src/util/schema-util.ts
index 2d6f1381..b04fbbc6 100644
--- a/client/src/playback/SchemaHelpers.ts
+++ b/client/src/util/schema-util.ts
@@ -1,5 +1,5 @@
 import { flatbuffers, schema } from 'battlecode-schema'
-import { Vector } from './Vector'
+import { Vector } from './vector'
 
 export const parseVecTable = (value: schema.VecTable) => {
     const result: Vector[] = []
diff --git a/client/src/playback/Vector.ts b/client/src/util/vector.ts
similarity index 100%
rename from client/src/playback/Vector.ts
rename to client/src/util/vector.ts

From bd9edf8e2b7a99ff5580c248f08a040cc711dca1 Mon Sep 17 00:00:00 2001
From: Aidan Blum Levine <aidanblumlevine@gmail.com>
Date: Wed, 10 Apr 2024 17:31:44 -0400
Subject: [PATCH 4/5] more rearranging

---
 client/src/components/basic-dialog.tsx        | 24 ++++++++++++++-----
 .../sidebar}/graphs/d3-histogram.tsx          |  2 +-
 .../sidebar}/graphs/d3-line-chart.tsx         |  2 +-
 .../sidebar}/graphs/quick-histogram.tsx       |  0
 .../sidebar}/graphs/quick-line-chart.tsx      |  2 +-
 client/src/current-game/Colors.ts             |  1 +
 .../sidebar-game-tab/histogram.tsx            |  2 +-
 .../sidebar-game-tab/resource-graph.tsx       |  2 +-
 client/src/util/hotkeys.ts                    | 15 ++++++++++++
 9 files changed, 39 insertions(+), 11 deletions(-)
 rename client/src/{current-game/sidebar-game-tab => components/sidebar}/graphs/d3-histogram.tsx (97%)
 rename client/src/{current-game/sidebar-game-tab => components/sidebar}/graphs/d3-line-chart.tsx (98%)
 rename client/src/{current-game/sidebar-game-tab => components/sidebar}/graphs/quick-histogram.tsx (100%)
 rename client/src/{current-game/sidebar-game-tab => components/sidebar}/graphs/quick-line-chart.tsx (96%)
 create mode 100644 client/src/current-game/Colors.ts
 create mode 100644 client/src/util/hotkeys.ts

diff --git a/client/src/components/basic-dialog.tsx b/client/src/components/basic-dialog.tsx
index 03ef20e4..fc556672 100644
--- a/client/src/components/basic-dialog.tsx
+++ b/client/src/components/basic-dialog.tsx
@@ -1,6 +1,7 @@
 import React, { PropsWithChildren } from 'react'
 import { useAppContext } from '../app-context'
 import { useKeyboard } from '../util/keyboard'
+import { useHotkeys } from '../util/hotkeys'
 
 interface Props {
     open: boolean
@@ -20,6 +21,17 @@ export const BasicDialog: React.FC<PropsWithChildren<Props>> = (props) => {
     const context = useAppContext()
     const keyboard = useKeyboard()
 
+    useHotkeys(
+        {
+            EscapeDialog: () => {
+                if (props.open && props.onCancel) {
+                    props.onCancel()
+                }
+            }
+        },
+        [props.open, props.onCancel]
+    )
+
     React.useEffect(() => {
         if (!props.open) return
 
@@ -41,12 +53,12 @@ export const BasicDialog: React.FC<PropsWithChildren<Props>> = (props) => {
         widthType == 'sm'
             ? 'w-4/6 md:w-3/5 lg:w-6/12'
             : widthType == 'md'
-            ? 'w-5/6 md:w-3/4 lg:w-7/12'
-            : widthType == 'lg'
-            ? 'w-5/6 md:w-4/5 lg:w-9/12'
-            : widthType == 'full'
-            ? 'w-5/6 md:w-5/6 lg:w-11/12'
-            : ''
+              ? 'w-5/6 md:w-3/4 lg:w-7/12'
+              : widthType == 'lg'
+                ? 'w-5/6 md:w-4/5 lg:w-9/12'
+                : widthType == 'full'
+                  ? 'w-5/6 md:w-5/6 lg:w-11/12'
+                  : ''
     return (
         <div className="fixed flex flex-col items-center justify-center w-full h-full top-0 left-0 bg-gray-500 bg-opacity-50 z-50">
             <div
diff --git a/client/src/current-game/sidebar-game-tab/graphs/d3-histogram.tsx b/client/src/components/sidebar/graphs/d3-histogram.tsx
similarity index 97%
rename from client/src/current-game/sidebar-game-tab/graphs/d3-histogram.tsx
rename to client/src/components/sidebar/graphs/d3-histogram.tsx
index 4645bab1..138aa779 100644
--- a/client/src/current-game/sidebar-game-tab/graphs/d3-histogram.tsx
+++ b/client/src/components/sidebar/graphs/d3-histogram.tsx
@@ -1,5 +1,5 @@
 import React, { useEffect, useRef } from 'react'
-import { TEAM_WHITE, TEAM_BROWN } from '../../Constants'
+import { TEAM_WHITE, TEAM_BROWN } from '../../../current-game/Constants'
 import * as d3 from 'd3'
 
 interface HistogramProps {
diff --git a/client/src/current-game/sidebar-game-tab/graphs/d3-line-chart.tsx b/client/src/components/sidebar/graphs/d3-line-chart.tsx
similarity index 98%
rename from client/src/current-game/sidebar-game-tab/graphs/d3-line-chart.tsx
rename to client/src/components/sidebar/graphs/d3-line-chart.tsx
index 93db7a8e..a2267b0a 100644
--- a/client/src/current-game/sidebar-game-tab/graphs/d3-line-chart.tsx
+++ b/client/src/components/sidebar/graphs/d3-line-chart.tsx
@@ -1,5 +1,5 @@
 import React, { useEffect, useRef } from 'react'
-import { TEAM_WHITE, TEAM_BROWN } from '../../Constants'
+import { TEAM_WHITE, TEAM_BROWN } from '../../../current-game/Constants'
 import * as d3 from 'd3'
 
 export interface LineChartDataPoint {
diff --git a/client/src/current-game/sidebar-game-tab/graphs/quick-histogram.tsx b/client/src/components/sidebar/graphs/quick-histogram.tsx
similarity index 100%
rename from client/src/current-game/sidebar-game-tab/graphs/quick-histogram.tsx
rename to client/src/components/sidebar/graphs/quick-histogram.tsx
diff --git a/client/src/current-game/sidebar-game-tab/graphs/quick-line-chart.tsx b/client/src/components/sidebar/graphs/quick-line-chart.tsx
similarity index 96%
rename from client/src/current-game/sidebar-game-tab/graphs/quick-line-chart.tsx
rename to client/src/components/sidebar/graphs/quick-line-chart.tsx
index 0c8f6f4f..99a834c3 100644
--- a/client/src/current-game/sidebar-game-tab/graphs/quick-line-chart.tsx
+++ b/client/src/components/sidebar/graphs/quick-line-chart.tsx
@@ -1,5 +1,5 @@
 import React, { useEffect, useRef } from 'react'
-import { TEAM_WHITE, TEAM_BROWN } from '../../Constants'
+import { TEAM_WHITE, TEAM_BROWN } from '../../../current-game/Constants'
 import { drawAxes, getAxes, setCanvasResolution } from '../../../util/graph-util'
 
 export interface LineChartDataPoint {
diff --git a/client/src/current-game/Colors.ts b/client/src/current-game/Colors.ts
new file mode 100644
index 00000000..8e923d3c
--- /dev/null
+++ b/client/src/current-game/Colors.ts
@@ -0,0 +1 @@
+// centralize colors here
\ No newline at end of file
diff --git a/client/src/current-game/sidebar-game-tab/histogram.tsx b/client/src/current-game/sidebar-game-tab/histogram.tsx
index eb09be1e..346eaebe 100644
--- a/client/src/current-game/sidebar-game-tab/histogram.tsx
+++ b/client/src/current-game/sidebar-game-tab/histogram.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import { AppContext, useAppContext } from '../../app-context'
 import { useListenEvent, EventType } from '../../app-events'
 import { useForceUpdate } from '../../util/react-util'
-import { CanvasHistogram } from './graphs/quick-histogram'
+import { CanvasHistogram } from '../../components/sidebar/graphs/quick-histogram'
 import { ATTACK_COLOR, SPECIALTY_COLORS, TEAM_COLORS } from '../Constants'
 
 function getChartData(appContext: AppContext): number[][][] {
diff --git a/client/src/current-game/sidebar-game-tab/resource-graph.tsx b/client/src/current-game/sidebar-game-tab/resource-graph.tsx
index 024bad83..05cabe14 100644
--- a/client/src/current-game/sidebar-game-tab/resource-graph.tsx
+++ b/client/src/current-game/sidebar-game-tab/resource-graph.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import { AppContext, useAppContext } from '../../app-context'
 import { useListenEvent, EventType } from '../../app-events'
 import { useForceUpdate } from '../../util/react-util'
-import { D3LineChart, LineChartDataPoint } from './graphs/d3-line-chart'
+import { D3LineChart, LineChartDataPoint } from '../../components/sidebar/graphs/d3-line-chart'
 import assert from 'assert'
 
 interface Props {
diff --git a/client/src/util/hotkeys.ts b/client/src/util/hotkeys.ts
new file mode 100644
index 00000000..bb4ae0fe
--- /dev/null
+++ b/client/src/util/hotkeys.ts
@@ -0,0 +1,15 @@
+import { useEffect } from 'react'
+import { useKeyboard } from './keyboard'
+
+enum Hotkeys {
+    EscapeDialog = 'Escape',
+}
+
+export const useHotkeys = (hotkeys: Record<string, () => void>) => {
+    const key = useKeyboard()
+    useEffect(() => {
+        if (hotkeys[key.keyCode]) {
+            hotkeys[key.keyCode]()
+        }
+    }, [key.keyCode])
+}

From c16023c5a33e021a9d3f2b059d6469e6ce419634 Mon Sep 17 00:00:00 2001
From: Aidan Blum Levine <aidanblumlevine@gmail.com>
Date: Sat, 20 Apr 2024 14:52:50 -0400
Subject: [PATCH 5/5] hotkeys done

---
 client/src/components/basic-dialog.tsx        | 26 ++----
 .../components/controls-bar/controls-bar.tsx  | 61 +++++++------
 client/src/components/sidebar/sidebar.tsx     | 19 ++--
 client/src/hotkeys.ts                         |  0
 client/src/util/hotkeys.ts                    | 15 ----
 client/src/util/keyboard.ts                   | 86 +++++++++++++++----
 6 files changed, 112 insertions(+), 95 deletions(-)
 create mode 100644 client/src/hotkeys.ts
 delete mode 100644 client/src/util/hotkeys.ts

diff --git a/client/src/components/basic-dialog.tsx b/client/src/components/basic-dialog.tsx
index fc556672..b616ffbf 100644
--- a/client/src/components/basic-dialog.tsx
+++ b/client/src/components/basic-dialog.tsx
@@ -1,7 +1,6 @@
 import React, { PropsWithChildren } from 'react'
 import { useAppContext } from '../app-context'
-import { useKeyboard } from '../util/keyboard'
-import { useHotkeys } from '../util/hotkeys'
+import { Hotkeys, useHotkey, useKeyboard } from '../util/keyboard'
 
 interface Props {
     open: boolean
@@ -21,25 +20,16 @@ export const BasicDialog: React.FC<PropsWithChildren<Props>> = (props) => {
     const context = useAppContext()
     const keyboard = useKeyboard()
 
-    useHotkeys(
-        {
-            EscapeDialog: () => {
-                if (props.open && props.onCancel) {
-                    props.onCancel()
-                }
-            }
+    useHotkey(
+        context.state,
+        keyboard,
+        Hotkeys.EscapeDialog,
+        () => {
+            if (props.open && props.onCancel) props.onCancel()
         },
-        [props.open, props.onCancel]
+        [props.onCancel, props.open]
     )
 
-    React.useEffect(() => {
-        if (!props.open) return
-
-        if (props.onCancel && keyboard.keyCode === 'Escape') {
-            props.onCancel()
-        }
-    }, [props.open, keyboard.keyCode])
-
     React.useEffect(() => {
         context.setState((prevState) => ({ ...prevState, disableHotkeys: props.open }))
     }, [props.open])
diff --git a/client/src/components/controls-bar/controls-bar.tsx b/client/src/components/controls-bar/controls-bar.tsx
index b52e59c6..fac456d4 100644
--- a/client/src/components/controls-bar/controls-bar.tsx
+++ b/client/src/components/controls-bar/controls-bar.tsx
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react'
 import * as ControlIcons from '../../icons/controls'
 import { ControlsBarButton } from './controls-bar-button'
 import { useAppContext } from '../../app-context'
-import { useKeyboard } from '../../util/keyboard'
+import { Hotkeys, useHotkey, useKeyboard } from '../../util/keyboard'
 import { ControlsBarTimeline } from './controls-bar-timeline'
 import { EventType, useListenEvent } from '../../app-events'
 import { useForceUpdate } from '../../util/react-util'
@@ -33,6 +33,33 @@ export const ControlsBar: React.FC<ControlsBarProps> = ({
     const { state: appState, setState: setAppState } = useAppContext()
     const [minimized, setMinimized] = React.useState(false)
     const keyboard = useKeyboard()
+    
+    useHotkey(appState, keyboard, Hotkeys.MinimizeControlBar, () => setMinimized(!minimized))
+    useHotkey(appState, keyboard, Hotkeys.Pause, () => setPaused(!paused))
+    useHotkey(
+        appState,
+        keyboard,
+        Hotkeys.ControlsNext,
+        () => {
+            if (paused) stepTurn(1)
+            else multiplyUpdatesPerSecond(2)
+        },
+        [paused],
+        true
+    )
+    useHotkey(
+        appState,
+        keyboard,
+        Hotkeys.ControlsPrev,
+        () => {
+            if (paused) stepTurn(-1)
+            else multiplyUpdatesPerSecond(0.5)
+        },
+        [paused],
+        true
+    )
+    useHotkey(appState, keyboard, Hotkeys.JumpToStart, () => jumpToTurn(0))
+    useHotkey(appState, keyboard, Hotkeys.JumpToEnd, () => jumpToEnd())
 
     const forceUpdate = useForceUpdate()
     useListenEvent(EventType.NEW_TURN, forceUpdate)
@@ -74,37 +101,7 @@ export const ControlsBar: React.FC<ControlsBarProps> = ({
         // control bar before using a shortcut, unselect it; Most browsers have
         // specific accessibility features that mess with these shortcuts.
         if (keyboard.targetElem instanceof HTMLButtonElement) keyboard.targetElem.blur()
-
-        if (keyboard.keyCode === 'Space' && match) setPaused(!paused)
-
-        if (keyboard.keyCode === 'KeyC') setMinimized(!minimized)
-
-        const applyArrows = () => {
-            if (paused) {
-                if (keyboard.keyCode === 'ArrowRight') stepTurn(1)
-                if (keyboard.keyCode === 'ArrowLeft') stepTurn(-1)
-            } else {
-                if (keyboard.keyCode === 'ArrowRight') multiplyUpdatesPerSecond(2)
-                if (keyboard.keyCode === 'ArrowLeft') multiplyUpdatesPerSecond(0.5)
-            }
-        }
-        applyArrows()
-
-        if (keyboard.keyCode === 'Comma') jumpToTurn(0)
-        if (keyboard.keyCode === 'Period') jumpToEnd()
-
-        const initalDelay = 250
-        const repeatDelay = 100
-        const timeouts: { initialTimeout: NodeJS.Timeout; repeatedFire?: NodeJS.Timeout } = {
-            initialTimeout: setTimeout(() => {
-                timeouts.repeatedFire = setInterval(applyArrows, repeatDelay)
-            }, initalDelay)
-        }
-        return () => {
-            clearTimeout(timeouts.initialTimeout)
-            clearInterval(timeouts.repeatedFire)
-        }
-    }, [keyboard.keyCode])
+    }, [appState, keyboard])
 
     if (!match?.isPlayable()) return null
 
diff --git a/client/src/components/sidebar/sidebar.tsx b/client/src/components/sidebar/sidebar.tsx
index 5fcb2ca6..58707540 100644
--- a/client/src/components/sidebar/sidebar.tsx
+++ b/client/src/components/sidebar/sidebar.tsx
@@ -8,7 +8,7 @@ import { HelpPage } from './help/help'
 import { MapEditorPage } from './map-editor/map-editor'
 import { RunnerPage } from './runner/runner'
 import { usePage, PageType, useSearchParamBool, useSearchParamString } from '../../app-search-params'
-import { useKeyboard } from '../../util/keyboard'
+import { Hotkeys, useHotkey, useKeyboard } from '../../util/keyboard'
 import { Scrollbars } from 'react-custom-scrollbars-2'
 import useWindowDimensions from '../../util/window-size'
 import { useAppContext } from '../../app-context'
@@ -17,13 +17,17 @@ import { ConfigPage } from '../../client-config'
 import { UpdateWarning } from './update-warning'
 import Game from '../../current-game/Game'
 import Tournament, { JsonTournamentGame } from '../game/tournament-renderer/Tournament'
-import { TournamentPage } from './tournament/tournament-page';
+import { TournamentPage } from './tournament/tournament-page'
 
 export const Sidebar: React.FC = () => {
     const { width, height } = useWindowDimensions()
     const [page, setPage] = usePage()
     const context = useAppContext()
     const keyboard = useKeyboard()
+    
+    useHotkey(context.state, keyboard, Hotkeys.BackSidebarPage, () => setPage(getNextPage(page, true)))
+    useHotkey(context.state, keyboard, Hotkeys.ForwardSidebarPage, () => setPage(getNextPage(page, false)))
+    useHotkey(context.state, keyboard, Hotkeys.JumpToQueue, () => setPage(PageType.QUEUE))
 
     // scaffold is created at this level so it is never re-created
     const scaffold = useScaffold()
@@ -129,17 +133,6 @@ export const Sidebar: React.FC = () => {
         return hotkeyPageLoop[nextIndex]
     }
 
-    React.useEffect(() => {
-        if (context.state.disableHotkeys) return
-
-        if (keyboard.keyCode === 'Backquote') setPage(getNextPage(page, true))
-        if (keyboard.keyCode === 'Digit1') setPage(getNextPage(page, false))
-
-        if (keyboard.keyCode == 'KeyO' && (keyboard.ctrlKey || keyboard.metaKey)) {
-            setPage(PageType.QUEUE)
-        }
-    }, [keyboard])
-
     const activeSidebarButtons = React.useMemo(() => {
         if (showTournamentFeatures) {
             return [
diff --git a/client/src/hotkeys.ts b/client/src/hotkeys.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/client/src/util/hotkeys.ts b/client/src/util/hotkeys.ts
deleted file mode 100644
index bb4ae0fe..00000000
--- a/client/src/util/hotkeys.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useEffect } from 'react'
-import { useKeyboard } from './keyboard'
-
-enum Hotkeys {
-    EscapeDialog = 'Escape',
-}
-
-export const useHotkeys = (hotkeys: Record<string, () => void>) => {
-    const key = useKeyboard()
-    useEffect(() => {
-        if (hotkeys[key.keyCode]) {
-            hotkeys[key.keyCode]()
-        }
-    }, [key.keyCode])
-}
diff --git a/client/src/util/keyboard.ts b/client/src/util/keyboard.ts
index ad8bdf02..fdfabef3 100644
--- a/client/src/util/keyboard.ts
+++ b/client/src/util/keyboard.ts
@@ -1,25 +1,54 @@
 import { useEffect, useState } from 'react'
+import { AppState } from '../app-context'
 
-interface KeyState {
-    keyCode: string
-    repeat: boolean
-    targetElem: EventTarget | null
-    ctrlKey: boolean
-    metaKey: boolean
-    shiftKey: boolean
+export enum Hotkeys {
+    EscapeDialog,
+    MinimizeControlBar,
+    Pause,
+    ControlsNext,
+    ControlsPrev,
+    JumpToStart,
+    JumpToEnd,
+    BackSidebarPage,
+    ForwardSidebarPage,
+    JumpToQueue
 }
 
-const DEFAULT_KEY_STATE: KeyState = {
-    keyCode: '',
-    repeat: false,
-    targetElem: null,
-    ctrlKey: false,
-    metaKey: false,
-    shiftKey: false
+const HotkeyDefs: Record<Hotkeys, Hotkey> = {
+    [Hotkeys.EscapeDialog]: { key: 'Escape' },
+    [Hotkeys.MinimizeControlBar]: { key: 'KeyC' },
+    [Hotkeys.Pause]: { key: 'Space' },
+    [Hotkeys.ControlsNext]: { key: 'ArrowRight' },
+    [Hotkeys.ControlsPrev]: { key: 'ArrowLeft' },
+    [Hotkeys.JumpToStart]: { key: 'Comma' },
+    [Hotkeys.JumpToEnd]: { key: 'Period' },
+    [Hotkeys.BackSidebarPage]: { key: 'Backquote' },
+    [Hotkeys.ForwardSidebarPage]: { key: 'Digit1' },
+    [Hotkeys.JumpToQueue]: { key: 'KeyO', ctrlOrMeta: true }
 }
 
-export function useKeyboard() {
-    const [key, setKey] = useState<KeyState>(DEFAULT_KEY_STATE)
+export function useHotkey(
+    state: AppState,
+    keyboard: KeyState,
+    hotkeyName: Hotkeys,
+    callback: () => void,
+    defs: any[] = [],
+    repeat: boolean = false
+) {
+    const deps = [state, keyboard.keyCode, keyboard.ctrlKey, keyboard.metaKey, hotkeyName, ...defs]
+    if (repeat) deps.push(keyboard) // keyboard is recreated on repeat fire, so this makes it refire on native key repeat
+
+    useEffect(() => {
+        if (state.disableHotkeys) return
+        const hotkey = HotkeyDefs[hotkeyName]
+        if (keyboard.keyCode !== hotkey.key) return
+        if (hotkey.ctrlOrMeta && !keyboard.ctrlKey && !keyboard.metaKey) return
+        callback()
+    }, deps)
+}
+
+export function useKeyboard(): KeyState {
+    const [pressedKey, setKey] = useState<KeyState>(DEFAULT_KEY_STATE)
 
     useEffect(() => {
         const pressedCallback = (e: KeyboardEvent) =>
@@ -42,5 +71,28 @@ export function useKeyboard() {
         }
     }, [])
 
-    return key
+    return pressedKey
+}
+
+interface Hotkey {
+    key: string
+    ctrlOrMeta?: boolean
+}
+
+interface KeyState {
+    keyCode: string
+    repeat: boolean
+    targetElem: EventTarget | null
+    ctrlKey: boolean
+    metaKey: boolean
+    shiftKey: boolean
+}
+
+const DEFAULT_KEY_STATE: KeyState = {
+    keyCode: '',
+    repeat: false,
+    targetElem: null,
+    ctrlKey: false,
+    metaKey: false,
+    shiftKey: false
 }