diff --git a/code/modules/modular_computers/file_system/programs/generic/fishing.dm b/code/modules/modular_computers/file_system/programs/generic/fishing.dm
new file mode 100644
index 0000000000..21a0ce203d
--- /dev/null
+++ b/code/modules/modular_computers/file_system/programs/generic/fishing.dm
@@ -0,0 +1,26 @@
+/datum/computer_file/program/fishing
+ filename = "fishingminigame"
+ filedesc = "Fishy Fishy 905"
+ program_icon_state = "arcade"
+ extended_desc = "A little fishing minigame for when you're really bored."
+ size = 3
+ requires_ntnet = FALSE
+ available_on_ntnet = TRUE
+ tgui_id = "NtosFishing"
+ usage_flags = PROGRAM_ALL
+
+/datum/computer_file/program/fishing/tgui_data(mob/user)
+ return get_header_data()
+
+/datum/computer_file/program/fishing/tgui_act(action, list/params)
+ . = ..()
+ if(.)
+ return
+
+ switch(action)
+ if("lose")
+ playsound(computer, 'sound/arcade/lose.ogg', 50, TRUE, extrarange = -3, falloff = 0.1)
+ . = TRUE
+ if("win")
+ playsound(computer, 'sound/arcade/win.ogg', 50, TRUE, extrarange = -3, falloff = 0.1)
+ . = TRUE
diff --git a/tgui/packages/tgui/interfaces/FishingMinigame.tsx b/tgui/packages/tgui/interfaces/FishingMinigame.tsx
new file mode 100644
index 0000000000..3e8cf16fb8
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/FishingMinigame.tsx
@@ -0,0 +1,360 @@
+import { clamp } from 'common/math';
+import {
+ Component,
+ PropsWithChildren,
+ ReactNode,
+ useEffect,
+ useState,
+} from 'react';
+import { Box } from 'tgui/components';
+import { Window } from 'tgui/layouts';
+import { Button, Icon, ProgressBar, Stack } from 'tgui-core/components';
+
+import { BoxProps } from '../components/Box';
+
+enum GameOverState {
+ GameRunning = 0,
+ GameWin = 1,
+ GameFail = 2,
+ GameTitle = 3,
+}
+
+export const FishingMinigame = (props) => {
+ return (
+
+
+
+
+
+ );
+};
+
+export const GameWindow = (props: {
+ onWin?: () => void;
+ onLose?: () => void;
+}) => {
+ const [gameOver, setGameOver] = useState(GameOverState.GameTitle);
+ const [score, setScore] = useState((MAX_SCORE + MIN_SCORE) / 2);
+
+ useEffect(() => {
+ if (score === MIN_SCORE) {
+ if (props.onLose) {
+ props.onLose();
+ }
+ setGameOver(GameOverState.GameFail);
+ } else if (score === MAX_SCORE) {
+ if (props.onWin) {
+ props.onWin();
+ }
+ setGameOver(GameOverState.GameWin);
+ }
+ }, [score]);
+
+ return gameOver ? (
+
+
+
+ {gameOver === GameOverState.GameFail
+ ? 'You Lost!'
+ : gameOver === GameOverState.GameWin
+ ? 'You won!!!'
+ : 'Fishy Fishy 905'}
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ Fish...
+
+
+ Fish!
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// Settings
+const UPDATE_RATE = 20; // ms per update interval
+const HEIGHT = 30; // total height
+const DIFFICULTY = 8; // vertical size of bar
+const FISH_SPASM_CHANCE = 5; // %chance of fish changing direction
+const BAR_UP_SPEED = 0.4; // amount bar moves up per tick
+const BAR_FALL_SPEED = 0.6; // amount bar moves down per tick
+const MAX_SCORE = 200;
+const MIN_SCORE = -200;
+// Reactive escape hatch
+let barTop = HEIGHT;
+let fishTop = 0;
+let fishDirection = 0.25;
+let mouseDown = false;
+
+export const Bar = (props: {
+ setScore: React.Dispatch>;
+}) => {
+ const { setScore } = props;
+
+ // Used to bridge non-reactive values back into reactive ones
+ const [barTopR, setBarTopR] = useState(HEIGHT);
+ const [fishTopR, setFishTopR] = useState(0);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ // Move bar
+ if (mouseDown) {
+ barTop = clamp(barTop - BAR_UP_SPEED, 0.25, HEIGHT);
+ } else {
+ barTop = clamp(barTop + BAR_FALL_SPEED, 0.25, HEIGHT);
+ }
+
+ // Move fish
+ if (Math.random() > 1 - FISH_SPASM_CHANCE / 100) {
+ fishDirection = -fishDirection;
+ }
+
+ fishTop = clamp(fishTop + fishDirection, 0.5, HEIGHT + 4);
+
+ // Detect fish being inside the bar
+ if (fishTop > barTop && fishTop < barTop + DIFFICULTY) {
+ setScore((x) => clamp(x + 1, MIN_SCORE, MAX_SCORE));
+ } else {
+ setScore((x) => clamp(x - 1, MIN_SCORE, MAX_SCORE));
+ }
+
+ // Update visuals
+ setBarTopR(barTop);
+ setFishTopR(fishTop);
+ }, UPDATE_RATE);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+ {
+ e.preventDefault();
+ mouseDown = true;
+ }}
+ onMouseUp={(e) => {
+ e.preventDefault();
+ mouseDown = false;
+ }}
+ >
+
+
+
+
+
+
+
+ );
+};
+
+type MouseTrackerProps = PropsWithChildren<{
+ onMouseDown?: (e: MouseEvent) => void;
+ onMouseUp?: (e: MouseEvent) => void;
+}>;
+
+export class MouseTracker extends Component {
+ constructor(props: MouseTrackerProps) {
+ super(props);
+ }
+
+ onMouseDown = (e) => {
+ if (this.props.onMouseDown) {
+ this.props.onMouseDown(e);
+ }
+ };
+
+ onMouseUp = (e) => {
+ if (this.props.onMouseUp) {
+ this.props.onMouseUp(e);
+ }
+ };
+
+ componentDidMount(): void {
+ document.addEventListener('mousedown', this.onMouseDown);
+ document.addEventListener('mouseup', this.onMouseUp);
+ }
+
+ componentWillUnmount(): void {
+ document.removeEventListener('mousedown', this.onMouseDown);
+ document.removeEventListener('mouseup', this.onMouseUp);
+ }
+
+ render(): ReactNode {
+ return {this.props.children};
+ }
+}
+
+const FishingRod = (props: BoxProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/NtosFishing.tsx b/tgui/packages/tgui/interfaces/NtosFishing.tsx
new file mode 100644
index 0000000000..742fab70d7
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/NtosFishing.tsx
@@ -0,0 +1,18 @@
+import { useBackend } from '../backend';
+import { NtosWindow } from '../layouts';
+import { GameWindow as FishingGame } from './FishingMinigame';
+
+type Data = {
+ PC_device_theme: string;
+};
+
+export const NtosFishing = (props) => {
+ const { act, data } = useBackend();
+ return (
+
+
+ act('lose')} onWin={() => act('win')} />
+
+
+ );
+};
diff --git a/tgui/packages/tgui/styles/interfaces/FishingMinigame.scss b/tgui/packages/tgui/styles/interfaces/FishingMinigame.scss
new file mode 100644
index 0000000000..1c22304cd2
--- /dev/null
+++ b/tgui/packages/tgui/styles/interfaces/FishingMinigame.scss
@@ -0,0 +1,3 @@
+.FishingGame__ProgressBar > div {
+ transition: none;
+}
diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss
index effa918048..6c9626dd9c 100644
--- a/tgui/packages/tgui/styles/main.scss
+++ b/tgui/packages/tgui/styles/main.scss
@@ -53,6 +53,7 @@
@include meta.load-css('./interfaces/Changelog.scss');
@include meta.load-css('./interfaces/CrewManifest.scss');
@include meta.load-css('./interfaces/ExperimentConfigure.scss');
+@include meta.load-css('./interfaces/FishingMinigame.scss');
@include meta.load-css('./interfaces/NuclearBomb.scss');
@include meta.load-css('./interfaces/Paper.scss');
@include meta.load-css('./interfaces/Pda.scss');
diff --git a/vorestation.dme b/vorestation.dme
index e47e1a0626..dc56ab780e 100644
--- a/vorestation.dme
+++ b/vorestation.dme
@@ -3427,6 +3427,7 @@
#include "code\modules\modular_computers\file_system\programs\generic\configurator.dm"
#include "code\modules\modular_computers\file_system\programs\generic\email_client.dm"
#include "code\modules\modular_computers\file_system\programs\generic\file_browser.dm"
+#include "code\modules\modular_computers\file_system\programs\generic\fishing.dm"
#include "code\modules\modular_computers\file_system\programs\generic\game.dm"
#include "code\modules\modular_computers\file_system\programs\generic\news_browser.dm"
#include "code\modules\modular_computers\file_system\programs\generic\ntdownloader.dm"