import { useViewport } from "@rbxts/pretty-react-hooks";
import React from "@rbxts/react";
import { Group } from "client/components/ui/group";
import { useOrientation, usePremium, useRem } from "client/hooks";
import { formatInteger } from "client/utils/format-integer";
import { DevProduct } from "shared/assets";
import { PREMIUM_BENEFIT } from "shared/constants/core";
import { palette } from "shared/constants/palette";

import { SupportHeart } from "./support-heart";
import { SupportProduct } from "./support-product";

export function SupportProducts() {
	const rem = useRem();
	const padding = rem(1.5);

	const viewport = useViewport();
	const orientation = useOrientation();
	const premium = usePremium();

	const getProductTitle = (money: number) => {
		if (premium) {
			money = math.floor(money * PREMIUM_BENEFIT);
		}

		return `$${formatInteger(money)}`;
	};

	const getProductDiscount = (money: number, discount?: string) => {
		return premium ? `<s>$${formatInteger(money)}</s> ${RobloxEmoji.Premium} BONUS!` : discount;
	};

	let index = 0;

	return (
		<scrollingframe
			CanvasSize={new UDim2(0, rem(70), 0, 0)}
			BackgroundTransparency={1}
			BorderSizePixel={0}
			ClipsDescendants={false}
			AnchorPoint={new Vector2(0.5, 0.5)}
			Size={new UDim2(0, rem(70), 0, rem(36))}
			Position={new UDim2(0.5, 0, 0.5, 16)}
		>
			<uisizeconstraint MaxSize={viewport.map((v) => new Vector2(v.X, math.huge))} />

			{orientation === "portrait" && (
				<uipadding
					PaddingLeft={new UDim(0, padding)}
					PaddingRight={new UDim(0, padding)}
					PaddingTop={new UDim(0, -2 * padding)}
					PaddingBottom={new UDim(0, 2 * padding)}
				/>
			)}

			<Group size={new UDim2(0.6, -padding / 2, 1, 0)}>
				<SupportProduct
					index={index++}
					productId={DevProduct.MONEY_100}
					productTitle={getProductTitle(100)}
					productSubtitle="🍑  PEACH"
					productDiscount={getProductDiscount(100)}
					primaryColor={palette.yellow}
					secondaryColor={palette.peach}
					size={new UDim2(0.5, -padding / 2, 0.5, -padding / 2)}
					position={new UDim2(0, 0, 0, 0)}
				/>
				<SupportProduct
					index={index++}
					productId={DevProduct.MONEY_250}
					productTitle={getProductTitle(250)}
					productSubtitle="🍒  MAROON"
					productDiscount={getProductDiscount(250, "20% OFF")}
					primaryColor={palette.maroon}
					secondaryColor={palette.red}
					size={new UDim2(0.5, -padding / 2, 0.5, -padding / 2)}
					position={new UDim2(0, 0, 0.5, padding / 2)}
				/>
				<SupportProduct
					index={index++}
					productId={DevProduct.MONEY_500}
					productTitle={getProductTitle(500)}
					productSubtitle="🍐  GREEN"
					productDiscount={getProductDiscount(500, "20% OFF")}
					primaryColor={palette.teal}
					secondaryColor={palette.green}
					size={new UDim2(0.5, -padding / 2, 0.5, -padding / 2)}
					position={new UDim2(0.5, padding / 2, 0, 0)}
				/>
				<SupportProduct
					index={index++}
					productId={DevProduct.MONEY_1000}
					productTitle={getProductTitle(1000)}
					productSubtitle="💎  SAPPHIRE"
					productDiscount={getProductDiscount(1000, "20% OFF")}
					primaryColor={palette.sapphire}
					secondaryColor={palette.blue}
					size={new UDim2(0.5, -padding / 2, 0.5, -padding / 2)}
					position={new UDim2(0.5, padding / 2, 0.5, padding / 2)}
				/>
			</Group>

			<SupportProduct
				index={index++}
				productId={DevProduct.MONEY_5000}
				productTitle={getProductTitle(5000)}
				productSubtitle="💜  MAUVE"
				productDiscount={getProductDiscount(5000, "25% OFF")}
				primaryColor={palette.mauve}
				secondaryColor={palette.blue}
				size={new UDim2(0.4, -padding / 2, 1, 0)}
				position={new UDim2(0.6, padding / 2, 0, 0)}
			>
				<SupportHeart />
			</SupportProduct>
		</scrollingframe>
	);
}


import React from "@rbxts/react";
import { RemProvider } from "client/providers/rem-provider";

import { SupportFooter } from "./support-footer";
import { SupportPremium } from "./support-premium";
import { SupportProducts } from "./support-products";

export function Support() {
	return (
		<>
			<RemProvider minimumRem={0}>
				<SupportProducts />
			</RemProvider>

			<SupportFooter />
			<SupportPremium />
		</>
	);
}


import { useEventListener } from "@rbxts/pretty-react-hooks";
import React, { useEffect, useState } from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { selectMusicEnabled } from "client/store/menu";
import { createSound } from "shared/assets";
import { shuffle } from "shared/utils/object-utils";

const MUSIC = [
	"rbxassetid://9046863253", // Poolside
	"rbxassetid://9046863960", // Beachwave
	"rbxassetid://9039767824", // Confession
	"rbxassetid://9039769202", // Santa Ervilio
	"rbxassetid://9039768724", // Friends
	"rbxassetid://9047050075", // Lo Fi Dreams Hip Hop
	"rbxassetid://9039771403", // Opportunity
	"rbxassetid://9039770227", // It s For Me
	"rbxassetid://9047105000", // I'll Show Ya
	"rbxassetid://9046863579", // City Lights
	"rbxassetid://9047105308", // Dusk To Dawn
	"rbxassetid://9047105702", // Light Dreamer
	"rbxassetid://9047105533", // No Smoking
	"rbxassetid://1848354536", // Relaxed Scene
	"rbxassetid://9043887091", // Lo-fi Chill A
	"rbxassetid://9044565954", // Smooth Vibes (c)
	"rbxassetid://1839841807", // Relax (c)
	"rbxassetid://1838979278", // Early Morning
	"rbxassetid://1841998846", // Lobby Soirée (c)
	"rbxassetid://9047104411", // Beach Cushions
];

export function Music() {
	const enabled = useSelector(selectMusicEnabled);

	const [queue, setQueue] = useState(() => shuffle(MUSIC));
	const [index, setIndex] = useState(0);
	const [sound, setSound] = useState<Sound>();

	// Advance the queue when the song ends
	useEventListener(sound?.Ended, () => {
		setIndex(index + 1);
	});

	// Create the next song when the index changes
	useEffect(() => {
		if (index >= queue.size()) {
			// Shuffle the queue if we've reached the end
			setQueue(shuffle(MUSIC));
			setIndex(0);
			return;
		}

		const newSound = createSound(queue[index], { volume: 0.2 });

		setSound(newSound);

		return () => {
			newSound.Destroy();
		};
	}, [index]);

	// Pause/resume the sound when the enabled state changes
	// or when the sound changes
	useEffect(() => {
		if (enabled) {
			sound?.Resume();
		} else {
			sound?.Pause();
		}
	}, [enabled, sound]);

	// Destroy sounds not in use
	useEffect(() => {
		return () => {
			sound?.Destroy();
		};
	}, [sound]);

	return <></>;
}


import { useAsyncEffect, useDeferState } from "@rbxts/pretty-react-hooks";
import React, { useMemo } from "@rbxts/react";
import { ContentProvider } from "@rbxts/services";
import { images, sounds } from "shared/assets";

import { useRem } from "../../hooks";
import { Text } from "../ui/text";

interface Assets {
	[key: string]: string | Assets;
}

export function Preloader() {
	const rem = useRem();

	const [contentIds, contentNamesById] = useMemo(() => {
		const contentIds: string[] = [];
		const contentNamesById = new Map<string, string>();

		const scan = (assets: Assets, prefix = "") => {
			for (const [name, asset] of pairs(assets)) {
				if (typeIs(asset, "string")) {
					contentIds.push(asset);
					contentNamesById.set(asset, `${prefix}${name}`);
				} else {
					scan(asset, `${prefix}${name}/`);
				}
			}
		};

		scan(images, "images/");
		scan(sounds, "sounds/");

		return [contentIds, contentNamesById] as const;
	}, []);

	const [currentAsset, setCurrentAsset] = useDeferState<string>();

	useAsyncEffect(async () => {
		ContentProvider.PreloadAsync(contentIds, (assetId) => {
			setCurrentAsset(contentNamesById.get(assetId));
		});

		setCurrentAsset(undefined);
	}, []);

	if (currentAsset === undefined) {
		return <></>;
	}

	return (
		<Text
			text={`Loading ${currentAsset}`}
			textSize={rem(2)}
			textXAlignment="Right"
			textYAlignment="Bottom"
			textColor={Color3.fromRGB(255, 255, 255)}
			textTransparency={0.2}
			position={new UDim2(1, rem(-2), 1, rem(-2))}
		>
			<uistroke
				Thickness={rem(0.1)}
				Color={Color3.fromRGB(0, 0, 0)}
				Transparency={0.5}
				ApplyStrokeMode="Contextual"
			/>
		</Text>
	);
}


import React from "@rbxts/react";
import { useSelector, useSelectorCreator } from "@rbxts/react-reflex";
import { Group } from "client/components/ui/group";
import { useDefined, useRem, useStore } from "client/hooks";
import { formatInteger } from "client/utils/format-integer";
import { USER_NAME } from "shared/constants/core";
import { selectPlayerBalance } from "shared/store/saves";
import { selectLocalEliminations, selectLocalScore, selectRankForDisplay } from "shared/store/snakes";

import { StatsCard } from "./stats-card";

export function Stats() {
	const rem = useRem();
	const store = useStore();

	const currentEliminations = useSelector(selectLocalEliminations);
	const currentScore = useSelector(selectLocalScore);
	const currentRank = useSelector(selectRankForDisplay);
	const currentBalance = useSelectorCreator(selectPlayerBalance, USER_NAME);

	// displays the previous value if any are set to undefined
	const eliminations = useDefined<string | number>(currentEliminations, "N/A");
	const score = useDefined<string | number>(currentScore, "N/A");
	const rank = useDefined(currentRank, "N/A");
	const balance = useDefined(currentBalance, 0);

	return (
		<Group>
			<uipadding PaddingBottom={new UDim(0, rem(3))} PaddingLeft={new UDim(0, rem(3))} />

			<uilistlayout
				FillDirection="Vertical"
				HorizontalAlignment="Left"
				VerticalAlignment="Bottom"
				Padding={new UDim(0, rem(1))}
				SortOrder="LayoutOrder"
			/>

			<StatsCard
				emoji="☠"
				label="KOs"
				value={`${formatInteger(eliminations)}`}
				primary={Color3.fromRGB(161, 163, 194)}
				secondary={Color3.fromRGB(97, 97, 138)}
				enabled={currentEliminations !== undefined}
				order={0}
			/>

			<StatsCard
				emoji="🏆"
				label="Rank"
				value={rank}
				primary={Color3.fromRGB(255, 203, 80)}
				secondary={Color3.fromRGB(255, 150, 79)}
				enabled={currentRank !== undefined}
				order={0}
			/>

			<StatsCard
				emoji="💯"
				label="Score"
				value={`${formatInteger(score)}`}
				primary={Color3.fromRGB(181, 64, 64)}
				secondary={Color3.fromRGB(150, 59, 84)}
				enabled={currentScore !== undefined}
				order={1}
			/>

			<StatsCard
				onClick={() => {
					if (currentScore === undefined) {
						// Only show the support page if the user is not playing
						store.setMenuPage("support");
					}
				}}
				emoji="💵"
				label="Cash"
				value={`$${formatInteger(balance)}`}
				primary={Color3.fromRGB(111, 158, 79)}
				secondary={Color3.fromRGB(153, 181, 107)}
				enabled={currentBalance !== undefined}
				order={2}
			/>
		</Group>
	);
}


import React from "@rbxts/react";

import { FrameProps } from "./frame";

export interface ButtonProps extends FrameProps<TextButton> {
	active?: boolean | React.Binding<boolean>;
	onClick?: () => void;
	onMouseDown?: () => void;
	onMouseUp?: () => void;
	onMouseEnter?: () => void;
	onMouseLeave?: () => void;
}

export function Button(props: ButtonProps) {
	const { onClick, onMouseDown, onMouseEnter, onMouseLeave, onMouseUp } = props;

	const event = {
		Activated: onClick && (() => onClick()),
		MouseButton1Down: onMouseDown && (() => onMouseDown()),
		MouseButton1Up: onMouseUp && (() => onMouseUp()),
		MouseEnter: onMouseEnter && (() => onMouseEnter()),
		MouseLeave: onMouseLeave && (() => onMouseLeave()),
		...props.event,
	};

	return (
		<textbutton
			Active={props.active}
			Text=""
			AutoButtonColor={false}
			Size={props.size}
			Position={props.position}
			AnchorPoint={props.anchorPoint}
			BackgroundColor3={props.backgroundColor}
			BackgroundTransparency={props.backgroundTransparency}
			ClipsDescendants={props.clipsDescendants}
			Visible={props.visible}
			ZIndex={props.zIndex}
			LayoutOrder={props.layoutOrder}
			BorderSizePixel={0}
			Event={event}
			Change={props.change}
		>
			{props.children}
			{props.cornerRadius && <uicorner CornerRadius={props.cornerRadius} />}
		</textbutton>
	);
}


import React, { forwardRef, Ref } from "@rbxts/react";

import { FrameProps } from "./frame";

export interface CanvasGroupProps extends FrameProps<CanvasGroup> {
	groupColor?: Color3 | React.Binding<Color3>;
	groupTransparency?: number | React.Binding<number>;
}

export const CanvasGroup = forwardRef((props: CanvasGroupProps, ref: Ref<CanvasGroup>) => {
	return (
		<canvasgroup
			ref={ref}
			GroupColor3={props.groupColor}
			GroupTransparency={props.groupTransparency}
			Size={props.size}
			Position={props.position}
			AnchorPoint={props.anchorPoint}
			BackgroundColor3={props.backgroundColor}
			BackgroundTransparency={props.backgroundTransparency}
			Rotation={props.rotation}
			ClipsDescendants={props.clipsDescendants}
			Visible={props.visible}
			ZIndex={props.zIndex}
			LayoutOrder={props.layoutOrder}
			BorderSizePixel={0}
			Event={props.event}
			Change={props.change}
		>
			{props.children}
			{props.cornerRadius && <uicorner CornerRadius={props.cornerRadius} />}
		</canvasgroup>
	);
});


import React, { useEffect, useState } from "@rbxts/react";
import { setTimeout } from "@rbxts/set-timeout";

interface DelayRenderProps extends React.PropsWithChildren {
	shouldRender: boolean;
	mountDelay?: number;
	unmountDelay?: number;
}

export function DelayRender({ shouldRender, mountDelay = 0, unmountDelay = 0, children }: DelayRenderProps) {
	const [render, setRender] = useState(false);

	useEffect(() => {
		return setTimeout(() => setRender(shouldRender), shouldRender ? mountDelay : unmountDelay);
	}, [shouldRender]);

	return <>{render && children}</>;
}


import React, { Component, ErrorInfo, ReactComponent } from "@rbxts/react";

interface ErrorBoundaryProps {
	fallback: (error: unknown) => React.Element;
}

interface ErrorBoundaryState {
	hasError: boolean;
	message?: unknown;
}

@ReactComponent
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
	public state: ErrorBoundaryState = {
		hasError: false,
	};

	public componentDidCatch(message: unknown, info: ErrorInfo) {
		warn(message, info.componentStack);

		this.setState({
			hasError: true,
			message: `${message} ${info.componentStack}`,
		});
	}

	public render() {
		if (this.state.hasError) {
			return this.props.fallback(this.state.message);
		} else {
			return this.props.children;
		}
	}
}


import React, { forwardRef, Ref } from "@rbxts/react";

export interface FrameProps<T extends Instance = Frame> extends React.PropsWithChildren {
	ref?: React.Ref<T>;
	event?: React.InstanceEvent<T>;
	change?: React.InstanceChangeEvent<T>;
	size?: UDim2 | React.Binding<UDim2>;
	position?: UDim2 | React.Binding<UDim2>;
	anchorPoint?: Vector2 | React.Binding<Vector2>;
	rotation?: number | React.Binding<number>;
	backgroundColor?: Color3 | React.Binding<Color3>;
	backgroundTransparency?: number | React.Binding<number>;
	clipsDescendants?: boolean | React.Binding<boolean>;
	visible?: boolean | React.Binding<boolean>;
	zIndex?: number | React.Binding<number>;
	layoutOrder?: number | React.Binding<number>;
	cornerRadius?: UDim | React.Binding<UDim>;
}

export const Frame = forwardRef((props: FrameProps, ref: Ref<Frame>) => {
	return (
		<frame
			ref={ref}
			Size={props.size}
			Position={props.position}
			AnchorPoint={props.anchorPoint}
			Rotation={props.rotation}
			BackgroundColor3={props.backgroundColor}
			BackgroundTransparency={props.backgroundTransparency}
			ClipsDescendants={props.clipsDescendants}
			Visible={props.visible}
			ZIndex={props.zIndex}
			LayoutOrder={props.layoutOrder}
			BorderSizePixel={0}
			Event={props.event}
			Change={props.change}
		>
			{props.children}
			{props.cornerRadius && <uicorner CornerRadius={props.cornerRadius} />}
		</frame>
	);
});


import React, { forwardRef } from "@rbxts/react";

interface GroupProps extends React.PropsWithChildren {
	ref?: React.Ref<Frame>;
	event?: React.InstanceEvent<Frame>;
	change?: React.InstanceChangeEvent<Frame>;
	size?: UDim2 | React.Binding<UDim2>;
	position?: UDim2 | React.Binding<UDim2>;
	anchorPoint?: Vector2 | React.Binding<Vector2>;
	rotation?: number | React.Binding<number>;
	clipsDescendants?: boolean | React.Binding<boolean>;
	layoutOrder?: number | React.Binding<number>;
	visible?: boolean | React.Binding<boolean>;
	zIndex?: number | React.Binding<number>;
}

export const Group = forwardRef((props: GroupProps, ref: React.Ref<Frame>) => {
	return (
		<frame
			ref={ref}
			Size={props.size || UDim2.fromScale(1, 1)}
			Position={props.position}
			AnchorPoint={props.anchorPoint}
			Rotation={props.rotation}
			ClipsDescendants={props.clipsDescendants}
			LayoutOrder={props.layoutOrder}
			Visible={props.visible}
			ZIndex={props.zIndex}
			BackgroundTransparency={1}
			Event={props.event}
			Change={props.change}
		>
			{props.children}
		</frame>
	);
});


import React from "@rbxts/react";

import { FrameProps } from "./frame";

export interface ImageProps extends FrameProps<ImageLabel> {
	image: string;
	imageColor?: Color3 | React.Binding<Color3>;
	imageTransparency?: number | React.Binding<number>;
	imageRectOffset?: Vector2 | React.Binding<Vector2>;
	imageRectSize?: Vector2 | React.Binding<Vector2>;
	scaleType?: React.InferEnumNames<Enum.ScaleType>;
	sliceScale?: number | React.Binding<number>;
	sliceCenter?: Rect | React.Binding<Rect>;
	tileSize?: UDim2 | React.Binding<UDim2>;
}

export function Image(props: ImageProps) {
	return (
		<imagelabel
			Image={props.image}
			ImageColor3={props.imageColor}
			ImageTransparency={props.imageTransparency}
			ImageRectOffset={props.imageRectOffset}
			ImageRectSize={props.imageRectSize}
			ScaleType={props.scaleType}
			SliceScale={props.sliceScale}
			SliceCenter={props.sliceCenter}
			TileSize={props.tileSize}
			Size={props.size}
			Position={props.position}
			AnchorPoint={props.anchorPoint}
			Rotation={props.rotation}
			BackgroundColor3={props.backgroundColor}
			BackgroundTransparency={props.backgroundTransparency ?? 1}
			ClipsDescendants={props.clipsDescendants}
			Visible={props.visible}
			ZIndex={props.zIndex}
			LayoutOrder={props.layoutOrder}
			BorderSizePixel={0}
			Event={props.event}
			Change={props.change}
		>
			{props.children}
			{props.cornerRadius && <uicorner CornerRadius={props.cornerRadius} />}
		</imagelabel>
	);
}


import { useEventListener } from "@rbxts/pretty-react-hooks";
import React, { useState } from "@rbxts/react";
import { UserInputService } from "@rbxts/services";
import { IS_EDIT } from "shared/constants/core";

import { Group } from "./group";

interface InputCaptureProps {
	readonly onInputBegan?: (rbx: Frame, input: InputObject) => void;
	readonly onInputChanged?: (rbx: Frame, input: InputObject) => void;
	readonly onInputEnded?: (rbx: Frame, input: InputObject) => void;
	readonly size?: UDim2;
	readonly position?: UDim2;
	readonly anchorPoint?: Vector2;
}

export function InputCapture({
	onInputBegan,
	onInputChanged,
	onInputEnded,
	size,
	position,
	anchorPoint,
}: InputCaptureProps) {
	const [frame, frameRef] = useState<Frame>();

	useEventListener(UserInputService.InputBegan, (input, gameProcessed) => {
		if (frame && !IS_EDIT && !gameProcessed) {
			onInputBegan?.(frame, input);
		}
	});

	useEventListener(UserInputService.InputEnded, (input) => {
		if (frame && !IS_EDIT) {
			onInputEnded?.(frame, input);
		}
	});

	useEventListener(UserInputService.InputChanged, (input) => {
		if (frame && !IS_EDIT) {
			onInputChanged?.(frame, input);
		}
	});

	return (
		<Group
			ref={frameRef}
			size={size}
			position={position}
			anchorPoint={anchorPoint}
			event={{
				InputBegan: IS_EDIT ? onInputBegan : undefined,
				InputChanged: IS_EDIT ? onInputChanged : undefined,
				InputEnded: IS_EDIT ? onInputEnded : undefined,
			}}
		/>
	);
}


import React from "@rbxts/react";
import { IS_EDIT } from "shared/constants/core";

import { Group } from "./group";

interface LayerProps extends React.PropsWithChildren {
	displayOrder?: number;
}

export function Layer({ displayOrder, children }: LayerProps) {
	return IS_EDIT ? (
		<Group zIndex={displayOrder}>{children}</Group>
	) : (
		<screengui ResetOnSpawn={false} DisplayOrder={displayOrder} IgnoreGuiInset ZIndexBehavior="Sibling">
			{children}
		</screengui>
	);
}


import { blend, composeBindings } from "@rbxts/pretty-react-hooks";
import React, { useMemo } from "@rbxts/react";
import { palette } from "shared/constants/palette";

import { useRem } from "../../hooks";
import { Group } from "./group";

interface OutlineProps extends React.PropsWithChildren {
	readonly outlineTransparency?: number | React.Binding<number>;
	readonly innerColor?: Color3 | React.Binding<Color3>;
	readonly outerColor?: Color3 | React.Binding<Color3>;
	readonly innerTransparency?: number | React.Binding<number>;
	readonly outerTransparency?: number | React.Binding<number>;
	readonly innerThickness?: number | React.Binding<number>;
	readonly outerThickness?: number | React.Binding<number>;
	readonly cornerRadius?: UDim | React.Binding<UDim>;
}

function ceilEven(n: number) {
	return math.ceil(n / 2) * 2;
}

export function Outline({
	outlineTransparency = 0,
	innerColor = palette.white,
	outerColor = palette.black,
	innerTransparency = 0.9,
	outerTransparency = 0.85,
	innerThickness,
	outerThickness,
	cornerRadius,
	children,
}: OutlineProps) {
	const rem = useRem();

	innerThickness ??= rem(3, "pixel");
	outerThickness ??= rem(1.5, "pixel");
	cornerRadius ??= new UDim(0, rem(0.5));

	const innerStyle = useMemo(() => {
		const size = composeBindings(innerThickness!, (thickness) => {
			return new UDim2(1, ceilEven(-2 * thickness), 1, ceilEven(-2 * thickness));
		});

		const position = composeBindings(innerThickness!, (thickness) => {
			return new UDim2(0, thickness, 0, thickness);
		});

		const radius = composeBindings(cornerRadius!, innerThickness!, (radius, thickness) => {
			return radius.sub(new UDim(0, thickness));
		});

		const transparency = composeBindings(outlineTransparency, innerTransparency, (a, b) => {
			return math.clamp(blend(a, b), 0, 1);
		});

		return { size, position, radius, transparency };
	}, [innerThickness, innerTransparency, cornerRadius, outlineTransparency, rem]);

	const outerStyle = useMemo(() => {
		const transparency = composeBindings(outlineTransparency, outerTransparency, (a, b) => {
			return math.clamp(blend(a, b), 0, 1);
		});

		return { transparency };
	}, [outlineTransparency, outerTransparency]);

	return (
		<>
			<Group size={innerStyle.size} position={innerStyle.position}>
				<uicorner CornerRadius={innerStyle.radius} />
				<uistroke Color={innerColor} Transparency={innerStyle.transparency} Thickness={innerThickness}>
					{children}
				</uistroke>
			</Group>

			<Group>
				<uicorner CornerRadius={cornerRadius} />
				<uistroke Color={outerColor} Transparency={outerStyle.transparency} Thickness={outerThickness}>
					{children}
				</uistroke>
			</Group>
		</>
	);
}


import { useEventListener, useLatest } from "@rbxts/pretty-react-hooks";
import { useMemo, useState } from "@rbxts/react";
import { UserInputService } from "@rbxts/services";
import { setTimeout } from "@rbxts/set-timeout";

import { useInputDevice } from "../../../hooks/use-input-device";

export interface ButtonEvents {
	onMouseDown: () => void;
	onMouseUp: () => void;
	onMouseEnter: () => void;
	onMouseLeave: () => void;
}

/**
 * Returns the current state of a button based on the events returned.
 * @param enabled Whether the button is enabled or not.
 * @returns The press state, hover state, and a `ButtonEvents` object.
 */
export function useButtonState(enabled = true): [press: boolean, hover: boolean, events: ButtonEvents] {
	const [{ press, hover }, setState] = useState({
		press: false,
		hover: false,
	});

	const on = useLatest(enabled);
	const touch = useLatest(useInputDevice() === "touch");

	const events: ButtonEvents = useMemo(() => {
		return {
			onMouseDown: () => setState((state) => ({ ...state, press: on.current })),
			onMouseUp: () => setState((state) => ({ ...state, press: false })),
			onMouseEnter: () => setState((state) => ({ ...state, hover: on.current && !touch.current })),
			onMouseLeave: () => setState({ press: false, hover: false }),
		};
	}, []);

	// Touch devices might not fire mouse leave events, so assume that all
	// releases are a mouse leave.
	useEventListener(UserInputService.InputEnded, (input) => {
		if (input.UserInputType === Enum.UserInputType.Touch) {
			setTimeout(() => {
				setState({ press: false, hover: false });
			}, 0);
		}
	});

	return [press, hover, events];
}


import { composeBindings } from "@rbxts/pretty-react-hooks";
import React from "@rbxts/react";
import { images } from "shared/assets";

import { useRem } from "../../hooks";
import { Image } from "./image";

interface ShadowProps extends React.PropsWithChildren {
	shadowBlur?: number;
	shadowPosition?: number | React.Binding<number>;
	shadowSize?: number | UDim2 | React.Binding<number | UDim2>;
	shadowColor?: Color3 | React.Binding<Color3>;
	shadowTransparency?: number | React.Binding<number>;
	zIndex?: number;
}

const IMAGE_SIZE = new Vector2(512, 512);
const BLUR_RADIUS = 80;

export function Shadow({
	shadowBlur = 1,
	shadowPosition,
	shadowSize = 0,
	shadowColor = new Color3(),
	shadowTransparency = 0.5,
	zIndex = -1,
	children,
}: ShadowProps) {
	const rem = useRem();

	shadowPosition ??= rem(1);

	return (
		<Image
			image={images.ui.blur}
			imageTransparency={shadowTransparency}
			imageColor={shadowColor}
			anchorPoint={new Vector2(0.5, 0.5)}
			size={composeBindings(shadowSize, (size) => {
				const sizeOffsetScaled = rem(BLUR_RADIUS * shadowBlur, "pixel");

				if (typeIs(size, "UDim2")) {
					return new UDim2(1, sizeOffsetScaled, 1, sizeOffsetScaled).add(size);
				} else {
					return new UDim2(1, size + sizeOffsetScaled, 1, size + sizeOffsetScaled);
				}
			})}
			position={composeBindings(shadowPosition, (offset) => new UDim2(0.5, 0, 0.5, offset))}
			scaleType="Slice"
			sliceCenter={new Rect(IMAGE_SIZE.div(2), IMAGE_SIZE.div(2))}
			sliceScale={rem(shadowBlur, "pixel")}
			zIndex={zIndex}
		>
			{children}
		</Image>
	);
}


import React, { useEffect, useState } from "@rbxts/react";

import { fonts } from "../../constants/fonts";
import { Group } from "./group";
import { TextProps } from "./text";

interface TextFieldProps extends TextProps<TextBox> {
	text?: string;
	placeholderText?: string | React.Binding<string>;
	placeholderColor?: Color3 | React.Binding<Color3>;
	clearTextOnFocus?: boolean | React.Binding<boolean>;
	multiLine?: boolean | React.Binding<boolean>;
	textEditable?: boolean | React.Binding<boolean>;
}

export function TextField(props: TextFieldProps) {
	const [childRef, setChildRef] = useState<Frame | undefined>(undefined);

	useEffect(() => {
		if (childRef && childRef.Parent?.IsA("TextBox")) {
			childRef.Parent.Text = props.text ?? "";
		}
	}, [childRef, props.text]);

	return (
		<textbox
			PlaceholderText={props.placeholderText}
			PlaceholderColor3={props.placeholderColor}
			ClearTextOnFocus={props.clearTextOnFocus}
			MultiLine={props.multiLine}
			TextEditable={props.textEditable}
			Font={Enum.Font.Unknown}
			FontFace={props.font || fonts.inter.regular}
			TextColor3={props.textColor}
			TextSize={props.textSize}
			TextTransparency={props.textTransparency}
			TextWrapped={props.textWrapped}
			TextXAlignment={props.textXAlignment}
			TextYAlignment={props.textYAlignment}
			TextTruncate={props.textTruncate}
			TextScaled={props.textScaled}
			AutomaticSize={props.textAutoResize}
			Size={props.size}
			Position={props.position}
			AnchorPoint={props.anchorPoint}
			BackgroundColor3={props.backgroundColor}
			BackgroundTransparency={props.backgroundTransparency ?? 1}
			ClipsDescendants={props.clipsDescendants}
			Visible={props.visible}
			ZIndex={props.zIndex}
			LayoutOrder={props.layoutOrder}
			BorderSizePixel={0}
			Event={props.event}
			Change={props.change}
		>
			{props.children}
			{props.cornerRadius && <uicorner CornerRadius={props.cornerRadius} />}
			<Group ref={setChildRef} />
		</textbox>
	);
}


import React from "@rbxts/react";
import { useRem } from "client/hooks";

import { fonts } from "../../constants/fonts";
import { FrameProps } from "./frame";

export interface TextProps<T extends Instance = TextLabel> extends FrameProps<T> {
	font?: Font;
	text?: string | React.Binding<string>;
	textColor?: Color3 | React.Binding<Color3>;
	textSize?: number | React.Binding<number>;
	textTransparency?: number | React.Binding<number>;
	textWrapped?: boolean | React.Binding<boolean>;
	textXAlignment?: React.InferEnumNames<Enum.TextXAlignment>;
	textYAlignment?: React.InferEnumNames<Enum.TextYAlignment>;
	textTruncate?: React.InferEnumNames<Enum.TextTruncate>;
	textScaled?: boolean | React.Binding<boolean>;
	textHeight?: number | React.Binding<number>;
	textAutoResize?: "X" | "Y" | "XY";
	richText?: boolean | React.Binding<boolean>;
	maxVisibleGraphemes?: number | React.Binding<number>;
}

export function Text(props: TextProps) {
	const rem = useRem();

	return (
		<textlabel
			Font={Enum.Font.Unknown}
			FontFace={props.font || fonts.inter.regular}
			Text={props.text}
			TextColor3={props.textColor}
			TextSize={props.textSize ?? rem(1)}
			TextTransparency={props.textTransparency}
			TextWrapped={props.textWrapped}
			TextXAlignment={props.textXAlignment}
			TextYAlignment={props.textYAlignment}
			TextTruncate={props.textTruncate}
			TextScaled={props.textScaled}
			LineHeight={props.textHeight}
			RichText={props.richText}
			MaxVisibleGraphemes={props.maxVisibleGraphemes}
			Size={props.size}
			AutomaticSize={props.textAutoResize}
			Position={props.position}
			AnchorPoint={props.anchorPoint}
			BackgroundColor3={props.backgroundColor}
			BackgroundTransparency={props.backgroundTransparency ?? 1}
			ClipsDescendants={props.clipsDescendants}
			Visible={props.visible}
			ZIndex={props.zIndex}
			LayoutOrder={props.layoutOrder}
			Change={props.change}
			Event={props.event}
		>
			{props.children}
			{props.cornerRadius && <uicorner CornerRadius={props.cornerRadius} />}
		</textlabel>
	);
}


import { getBindingValue, useEventListener, useUnmountEffect } from "@rbxts/pretty-react-hooks";
import React, { Binding, useMemo, useState } from "@rbxts/react";
import { createPortal } from "@rbxts/react-roblox";
import { RunService } from "@rbxts/services";
import { palette } from "shared/constants/palette";

interface TransitionProps extends React.PropsWithChildren {
	groupColor?: Color3 | Binding<Color3>;
	groupTransparency?: number | Binding<number>;
	anchorPoint?: Vector2 | Binding<Vector2>;
	size?: UDim2 | Binding<UDim2>;
	position?: UDim2 | Binding<UDim2>;
	rotation?: number | Binding<number>;
	clipsDescendants?: boolean | Binding<boolean>;
	layoutOrder?: number | Binding<number>;
	zIndex?: number | Binding<number>;
	event?: React.InstanceEvent<Frame | CanvasGroup>;
	change?: React.InstanceChangeEvent<Frame | CanvasGroup>;
	directChildren?: React.ReactNode;
	children?: React.ReactNode;
}

const EPSILON = 0.03;

export function Transition({
	groupColor,
	groupTransparency,
	anchorPoint,
	size = new UDim2(1, 0, 1, 0),
	position,
	rotation,
	clipsDescendants,
	layoutOrder,
	zIndex,
	event,
	change,
	children,
	directChildren,
}: TransitionProps) {
	const [frame, setFrame] = useState<Frame>();
	const [canvas, setCanvas] = useState<CanvasGroup>();

	const container = useMemo(() => {
		const container = new Instance("Frame");
		container.Size = new UDim2(1, 0, 1, 0);
		container.BackgroundTransparency = 1;
		return container;
	}, []);

	useEventListener(RunService.Heartbeat, () => {
		const transparency = getBindingValue(groupTransparency) ?? 0;
		const color = getBindingValue(groupColor) || palette.white;

		pcall(() => {
			if (transparency > EPSILON || color !== palette.white) {
				container.Parent = canvas;
			} else {
				container.Parent = frame;
			}
		});
	});

	useUnmountEffect(() => {
		container.Destroy();
	});

	return (
		<frame
			BackgroundTransparency={1}
			AnchorPoint={anchorPoint}
			Size={size}
			Position={position}
			Rotation={rotation}
			LayoutOrder={layoutOrder}
			ZIndex={zIndex}
		>
			{createPortal(<>{children}</>, container)}

			<canvasgroup
				ref={setCanvas}
				Change={change}
				Event={event}
				GroupTransparency={groupTransparency}
				GroupColor3={groupColor}
				BackgroundTransparency={1}
				Size={new UDim2(1, 0, 1, 0)}
			>
				{directChildren}
			</canvasgroup>

			<frame
				ref={setFrame}
				Change={change}
				Event={event}
				ClipsDescendants={clipsDescendants}
				BackgroundTransparency={1}
				Size={new UDim2(1, 0, 1, 0)}
			>
				{directChildren}
			</frame>
		</frame>
	);
}


import { useEventListener } from "@rbxts/pretty-react-hooks";
import { useEffect, useState } from "@rbxts/react";
import { Players } from "@rbxts/services";
import { Character, promiseCharacter } from "shared/utils/player-utils";

interface VoiceCharacter {
	readonly player: Player;
	readonly model: Character;
}

export function useVoiceCharacters() {
	const [characters, setCharacters] = useState<VoiceCharacter[]>([]);

	const characterAdded = async (model: Model) => {
		const character = await promiseCharacter(model).timeout(60, `Took too long to load character ${model}`);
		const player = Players.GetPlayerFromCharacter(character);

		if (player) {
			setCharacters((characters) => [...characters, { player, model: character }]);
		}
	};

	const characterRemoving = (model: Model) => {
		setCharacters((characters) => {
			return characters.filter((character) => character.model !== model);
		});
	};

	const playerAdded = async (player: Player) => {
		if (player.Character) {
			characterAdded(player.Character);
		}

		player.CharacterAdded.Connect(characterAdded);
		player.CharacterRemoving.Connect(characterRemoving);
	};

	const playerRemoving = (player: Player) => {
		setCharacters((characters) => {
			return characters.filter((character) => character.player !== player);
		});
	};

	useEventListener(Players.PlayerAdded, playerAdded);

	useEventListener(Players.PlayerRemoving, playerRemoving);

	useEffect(() => {
		for (const player of Players.GetPlayers()) {
			playerAdded(player);
		}
	}, []);

	return characters;
}

export function toRealSpace(position: Vector2) {
	return new CFrame(2 * position.X, 0, 2 * position.Y);
}


import { useCamera, useEventListener, useInterval } from "@rbxts/pretty-react-hooks";
import React from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { RunService } from "@rbxts/services";
import { selectSnakeFromWorldSubject } from "client/store/world";

import { toRealSpace } from "./utils";

export function VoiceCamera() {
	const camera = useCamera();
	const snake = useSelector(selectSnakeFromWorldSubject);

	const getCameraCFrame = (position: Vector2) => {
		const origin = toRealSpace(position).Position;
		return CFrame.lookAt(origin, origin.add(new Vector3(0, -1, 0)), new Vector3(0, 0, -1));
	};

	useEventListener(RunService.RenderStepped, () => {
		if (snake) {
			camera.CFrame = getCameraCFrame(snake.head);
		}
	});

	useInterval(() => {
		camera.CameraType = Enum.CameraType.Scriptable;
	}, 1);

	return <></>;
}


import { useInterval } from "@rbxts/pretty-react-hooks";
import React, { useRef } from "@rbxts/react";
import { useSelectorCreator } from "@rbxts/react-reflex";
import { selectSnakeById } from "shared/store/snakes";
import { Character } from "shared/utils/player-utils";

import { toRealSpace } from "./utils";

interface VoiceCharacterProps {
	readonly player: Player;
	readonly model: Character;
}

export function VoiceCharacter({ player, model }: VoiceCharacterProps) {
	const snake = useSelectorCreator(selectSnakeById, player.Name);
	const hidden = useRef<Model>();

	useInterval(() => {
		if (snake) {
			model.PivotTo(toRealSpace(snake.head));
		} else if (hidden.current !== model) {
			model.PivotTo(new CFrame(100, 200, 100));
			hidden.current = model;
		}
	}, 0.2);

	return <></>;
}


import React, { useMemo } from "@rbxts/react";
import { Players } from "@rbxts/services";

import { useVoiceCharacters } from "./utils";
import { VoiceCamera } from "./voice-camera";
import { VoiceCharacter } from "./voice-character";

export function Voice() {
	const characters = useVoiceCharacters();

	const charactersExcludingSelf = useMemo(() => {
		return characters.filter((voiceCharacter) => {
			return voiceCharacter.player !== Players.LocalPlayer;
		});
	}, [characters]);

	return (
		<>
			<VoiceCamera />

			{charactersExcludingSelf.map(({ player, model }) => (
				<VoiceCharacter key={player.Name} player={player} model={model} />
			))}
		</>
	);
}


import Object from "@rbxts/object-utils";
import { map, useCamera, useEventListener } from "@rbxts/pretty-react-hooks";
import React, { useBinding, useMemo } from "@rbxts/react";
import { RunService } from "@rbxts/services";
import { Image } from "client/components/ui/image";
import { useSeed } from "client/hooks";
import { images } from "shared/assets";
import { accents } from "shared/constants/palette";

interface BackdropBallProps {
	readonly smoothOffset: React.Binding<Vector2>;
}

/**
 * Constrains a value within a range by applying a modulo operation.
 */
function mod(value: number, min: number, max: number) {
	const range = max - min;
	return ((value - min) % range) + min;
}

export function BackdropBall({ smoothOffset }: BackdropBallProps) {
	const camera = useCamera();
	const seed = useSeed();
	const [timer, setTimer] = useBinding(0);

	const color = useMemo(() => {
		const colors = Object.values(accents);
		return colors[math.random(0, colors.size() - 1)];
	}, []);

	const style = useMemo(() => {
		const position = timer.map((t) => {
			const aspectRatio = camera.ViewportSize.X / camera.ViewportSize.Y;
			const offset = smoothOffset.getValue();

			const noiseX = map(math.noise(t, seed), -0.5, 0.5, -3, 4);
			const noiseY = map(math.noise(seed, t + 100), -0.5, 0.5, -3, 4);

			const x = mod(noiseX + 0.02 * offset.X, -1, 2);
			const y = mod(noiseY + 0.02 * offset.Y * aspectRatio, -1, 2);

			return new UDim2(x, 0, y, 0);
		});

		const size = timer.map((t) => {
			const diameter = map(math.noise(t + 100, seed), -0.5, 0.5, 1, 2);
			return new UDim2(diameter, 0, diameter, 0);
		});

		const transparency = timer.map((t) => {
			return map(math.noise(5 * t + 200, seed), -0.5, 0.5, 0.3, 0.8);
		});

		return { position, size, transparency };
	}, []);

	useEventListener(RunService.Heartbeat, (deltaTime) => {
		setTimer(timer.getValue() + 0.01 * deltaTime);
	});

	return (
		<Image
			image={images.ui.blur}
			imageColor={color}
			imageTransparency={style.transparency}
			scaleType="Fit"
			anchorPoint={new Vector2(0.5, 0.5)}
			size={style.size}
			position={style.position}
		/>
	);
}


import { useCamera, usePrevious, useUnmountEffect } from "@rbxts/pretty-react-hooks";
import { useEffect, useMemo, useState } from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { useRem } from "client/hooks";
import { CandyEntity, selectCandiesById } from "shared/store/candy";
import { Grid } from "shared/utils/grid";

const MARGIN = 8;

export function useCandyOnScreen(offset: Vector2, scale: number) {
	const rem = useRem();
	const camera = useCamera();

	const grid = useMemo(() => {
		return new Grid<{ id: string }>(5);
	}, []);

	const currentCandies = useSelector(selectCandiesById);
	const previousCandies = usePrevious(currentCandies) || {};

	const [candiesOnScreen, setCandiesOnScreen] = useState<CandyEntity[]>([]);

	useEffect(() => {
		for (const [, candy] of pairs(currentCandies)) {
			const previousCandy = previousCandies[candy.id];

			if (!previousCandy) {
				grid.insert(candy.position, { id: candy.id });
			} else if (candy.position !== previousCandy.position) {
				grid.replace(previousCandy.position, candy.position, { id: candy.id });
			}
		}

		for (const [, candy] of pairs(previousCandies)) {
			if (currentCandies[candy.id] === undefined) {
				grid.remove(candy.position);
			}
		}
	}, [currentCandies]);

	useEffect(() => {
		const viewport = camera.ViewportSize.div(rem(scale));
		const margin = new Vector2(MARGIN, MARGIN).div(scale);

		const boxPosition = viewport.div(2).add(offset).add(margin).mul(-1);
		const boxSize = viewport.add(margin.mul(2));

		const candiesOnScreen = grid.queryBox(boxPosition, boxSize).mapFiltered((point) => {
			return currentCandies[point.metadata.id];
		});

		setCandiesOnScreen(candiesOnScreen);
	}, [currentCandies, offset, scale, rem]);

	useUnmountEffect(() => {
		grid.clear();
	});

	return candiesOnScreen;
}


import { blend, lerp } from "@rbxts/pretty-react-hooks";
import React, { memo, useMemo } from "@rbxts/react";
import { useRem } from "client/hooks";
import { images } from "shared/assets";
import { getSnakeSkin, getSnakeSkinForTracer } from "shared/constants/skins";

import { SnakeEffectBinding, SnakeLineBinding } from "./use-snake-bindings";
import { useTracerStyle } from "./use-tracer-style";

interface SnakeTracerProps {
	readonly line: SnakeLineBinding;
	readonly effects: SnakeEffectBinding;
	readonly skinId: string;
	readonly index: number;
}

function SnakeTracerComponent({ line, effects, skinId, index }: SnakeTracerProps) {
	const skin = getSnakeSkin(skinId);
	const tracerSkin = getSnakeSkinForTracer(skinId, index);

	const rem = useRem();
	const style = useTracerStyle(line, effects, index, tracerSkin.tint, tracerSkin.boostTint);

	const glowStyle = useMemo(() => {
		const binding = style.boostTimer.map(() => {
			return effects.getValue();
		});

		const transparency = binding.map(({ boost, dead }) => {
			return blend(1 - boost, dead, 0.3);
		});

		const color = (style.boostColor || style.color).map((color) => {
			return color.Lerp(new Color3(), 0.1);
		});

		const size = binding.map(({ boost, dead }) => {
			const alpha = lerp(boost, 0, dead);
			const diameter = line.getValue().diameter;
			return new UDim2(1, 0, 1, 0).Lerp(new UDim2(1, rem(diameter + 5), 1, rem(diameter + 5)), alpha);
		});

		const visible = binding.map(({ boost, dead }) => {
			const alpha = lerp(boost, 0, dead);
			return alpha > 0;
		});

		return { transparency, color, size, visible };
	}, [style, rem, skin]);

	return (
		<imagelabel
			Image={tracerSkin.texture}
			ImageColor3={style.color}
			ImageTransparency={style.transparency}
			ScaleType="Slice"
			SliceCenter={new Rect(skin.size.div(2), skin.size.div(2))}
			SliceScale={4}
			BackgroundTransparency={1}
			AnchorPoint={new Vector2(0.5, 0.5)}
			Size={style.size}
			Position={style.position}
			Rotation={style.rotation}
			ZIndex={-index - 1}
		>
			{style.boostActive && (
				<imagelabel
					Image={images.ui.blur}
					ImageColor3={glowStyle.color}
					ImageTransparency={glowStyle.transparency}
					ScaleType="Slice"
					SliceCenter={new Rect(256, 256, 256, 256)}
					BackgroundTransparency={1}
					AnchorPoint={new Vector2(0.5, 0.5)}
					Size={glowStyle.size}
					Position={new UDim2(0.5, 0, 0.5, 0)}
				/>
			)}
		</imagelabel>
	);
}

export const SnakeTracer = memo(SnakeTracerComponent);


import React, { memo, useEffect, useMemo } from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { selectSkinOverride } from "client/store/menu";
import { USER_NAME } from "shared/constants/core";
import { describeSnakeFromScore } from "shared/store/snakes";

import { SnakeHead } from "./snake-head";
import { SnakeNameTag } from "./snake-name-tag";
import { SnakeTracer } from "./snake-tracer";
import { SnakeBindings, useSnakeBindings } from "./use-snake-bindings";
import { SnakeOnScreen } from "./use-snakes-on-screen";

interface SnakeProps {
	readonly snakeOnScreen: SnakeOnScreen;
	readonly scale: number;
	readonly offset: Vector2;
	readonly subject?: string;
	readonly setSnakeBindings: (bindings: SnakeBindings) => void;
}

function SnakeComponent({ snakeOnScreen, scale, offset, subject, setSnakeBindings }: SnakeProps) {
	const snake = snakeOnScreen.snake;
	const snakeBindings = useSnakeBindings(snakeOnScreen, scale, snake.id === subject);
	const snakeSkinOverride = useSelector(selectSkinOverride);

	const radius = describeSnakeFromScore(snake.score).radius;
	const distance = snake.head.sub(offset.mul(-1)).Magnitude;
	const skin = snake.id !== subject ? snake.skin : (snakeSkinOverride ?? snake.skin);
	const showNameTag = snake.id !== subject && !snake.dead && distance < 16;

	const children = useMemo(() => {
		return snakeOnScreen.tracers.mapFiltered((tracer) => {
			const index = tracer.index;
			const bindings = snakeBindings.bindings.get(index);

			if (!bindings) {
				return;
			}

			return (
				<SnakeTracer
					key={`tracer-${index}`}
					line={bindings.line}
					effects={bindings.effects}
					index={index}
					skinId={skin}
				/>
			);
		});
	}, [snakeOnScreen]);

	useEffect(() => {
		if (snake.id === subject) {
			setSnakeBindings(snakeBindings);
		}
	}, [snakeBindings, subject]);

	return (
		<>
			{children}
			{snakeOnScreen.head && (
				<SnakeHead
					angle={snake.angle}
					desiredAngle={snake.desiredAngle}
					line={snakeBindings.head.line}
					effects={snakeBindings.head.effects}
					skinId={skin}
					isClient={snake.id === USER_NAME}
				>
					<SnakeNameTag
						name={snake.name}
						head={snake.head}
						headOffset={offset}
						angle={snake.angle}
						radius={radius}
						scale={scale}
						skin={skin}
						visible={showNameTag}
					/>
				</SnakeHead>
			)}
		</>
	);
}

export const Snake = memo(SnakeComponent);


import { useCamera, useUnmountEffect } from "@rbxts/pretty-react-hooks";
import { useEffect, useMemo, useState } from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { useRem } from "client/hooks";
import { selectSnakesById, SnakeEntity } from "shared/store/snakes";
import { Grid } from "shared/utils/grid";

import { SNAKE_ON_SCREEN_MARGIN } from "./constants";

export interface SnakeOnScreen {
	readonly snake: SnakeEntity;
	readonly tracers: SnakeTracer[];
	head?: SnakeTracer;
}

export interface SnakeTracer {
	readonly position: Vector2;
	readonly index: number;
}

interface TracerCell {
	readonly id: string;
	readonly type: "tracer" | "head";
	readonly index: number;
}

export function useSnakesOnScreen(scale: number, offset: Vector2) {
	const rem = useRem();
	const camera = useCamera();
	const snakes = useSelector(selectSnakesById);

	const [onScreen, setOnScreen] = useState<SnakeOnScreen[]>([]);

	const grid = useMemo(() => {
		return new Grid<TracerCell>(10);
	}, []);

	useEffect(() => {
		grid.clear();

		for (const [, snake] of pairs(snakes)) {
			grid.insert(snake.head, { id: snake.id, type: "head", index: -1 });

			snake.tracers.forEach((tracer, index) => {
				grid.insert(tracer, { id: snake.id, type: "tracer", index });
			});
		}
	}, [snakes]);

	useEffect(() => {
		// query on-screen snakes and update the state
		const viewport = camera.ViewportSize.div(rem(scale));
		const margin = new Vector2(SNAKE_ON_SCREEN_MARGIN, SNAKE_ON_SCREEN_MARGIN).div(scale);

		const boxPosition = viewport.div(2).add(offset).add(margin).mul(-1);
		const boxSize = viewport.add(margin.mul(2));

		const snakesOnScreenById = new Map<string, SnakeOnScreen>();
		const snakesOnScreen: SnakeOnScreen[] = [];

		for (const point of grid.queryBox(boxPosition, boxSize)) {
			const snake = snakes[point.metadata.id];

			if (!snake) {
				continue;
			}

			const snakeOnScreen = snakesOnScreenById.get(point.metadata.id);
			const snakeTracer: SnakeTracer = {
				position: point.position,
				index: point.metadata.index,
			};

			if (snakeOnScreen) {
				if (snakeTracer.index === -1) {
					snakeOnScreen.head = snakeTracer;
					continue;
				}

				// insert the tracer at the correct index to end up with
				// a sorted array of tracers by index
				const insertAt = snakeOnScreen.tracers.findIndex((tracer) => {
					return tracer.index > snakeTracer.index;
				});

				if (insertAt === -1) {
					snakeOnScreen.tracers.push(snakeTracer);
				} else {
					snakeOnScreen.tracers.insert(insertAt, snakeTracer);
				}

				continue;
			}

			const newSnakeOnScreen: SnakeOnScreen = {
				snake,
				tracers: snakeTracer.index === -1 ? [] : [snakeTracer],
				head: snakeTracer.index === -1 ? snakeTracer : undefined,
			};

			snakesOnScreen.push(newSnakeOnScreen);
			snakesOnScreenById.set(point.metadata.id, newSnakeOnScreen);
		}

		setOnScreen(snakesOnScreen);
	}, [snakes, scale, offset, rem]);

	useUnmountEffect(() => {
		grid.clear();
	});

	return onScreen;
}


import { useBindingState, useTimer } from "@rbxts/pretty-react-hooks";
import { useEffect, useMemo } from "@rbxts/react";
import { setTimeout } from "@rbxts/set-timeout";
import { useRem } from "client/hooks";
import { brighten } from "shared/utils/color-utils";

import { SNAKE_ANGLE_OFFSET } from "./constants";
import { SnakeEffectBinding, SnakeLineBinding } from "./use-snake-bindings";

function redden(color: Color3) {
	return color.Lerp(Color3.fromRGB(255, 0, 0), 0.3);
}

export function useTracerStyle(
	line: SnakeLineBinding,
	effects: SnakeEffectBinding,
	index: number,
	tint: Color3,
	boostTint?: Color3,
) {
	const rem = useRem();
	const boostTimer = useTimer();
	const boostActive = useBindingState(effects.map(({ boost }) => boost > 0.01));

	// keep a timer running while boosting to keep animations going
	useEffect(() => {
		if (boostActive) {
			boostTimer.start();
			return;
		}

		return setTimeout(() => {
			boostTimer.stop();
		}, 1);
	}, [boostActive]);

	return useMemo(() => {
		const size = line.map(({ diameter, fromX, fromY, toX, toY }) => {
			const length = new Vector2(fromX - toX, fromY - toY).Magnitude;
			return new UDim2(0, rem(diameter), 0, rem(diameter + length));
		});

		const position = line.map(({ fromX, fromY, toX, toY }) => {
			return new UDim2(0, rem((fromX + toX) / 2), 0, rem((fromY + toY) / 2));
		});

		const rotation = line.map(({ fromX, fromY, toX, toY }) => {
			return math.deg(math.atan2(toY - fromY, toX - fromX) + SNAKE_ANGLE_OFFSET);
		});

		const color = boostTimer.value.map(() => {
			const { boost, dead } = effects.getValue();

			const time = os.clock();
			const highlight = math.sin(15 * time - 0.8 * index);

			return tint.Lerp(brighten(tint, highlight), boost).Lerp(redden(tint), dead);
		});

		const boostColor =
			boostTint &&
			boostTimer.value.map(() => {
				const { boost } = effects.getValue();

				const time = os.clock();
				const highlight = math.sin(15 * time - 0.8 * index);

				return boostTint.Lerp(brighten(boostTint, highlight), boost);
			});

		const transparency = effects.map(({ dead }) => {
			return dead;
		});

		return {
			size,
			position,
			rotation,
			color,
			boostColor,
			transparency,
			boostTimer: boostTimer.value,
			boostActive,
		};
	}, [line, effects, tint, rem, boostActive]);
}


import { useDebounceEffect, usePrevious } from "@rbxts/pretty-react-hooks";
import React, { useEffect } from "@rbxts/react";
import { useSelector, useSelectorCreator } from "@rbxts/react-reflex";
import { selectSnakeFromWorldSubject } from "client/store/world";
import { playSound, sounds } from "shared/assets";
import { selectHasLocalSnake, selectSnakeIsBoosting } from "shared/store/snakes";

const ERROR_SOUNDS = [sounds.error_1, sounds.error_2, sounds.error_3];

const random = new Random();

export function WorldSounds() {
	const snake = useSelector(selectSnakeFromWorldSubject);
	const boosting = useSelectorCreator(selectSnakeIsBoosting, snake?.id ?? "");
	const hasLocalSnake = useSelector(selectHasLocalSnake);
	const previousScore = usePrevious(snake?.score);

	const volume = hasLocalSnake ? 0.5 : 0.25;

	// Death sound
	useEffect(() => {
		if (snake?.dead) {
			const index = random.NextInteger(0, ERROR_SOUNDS.size() - 1);
			playSound(ERROR_SOUNDS[index], { volume: 2 * volume });
		}
	}, [snake?.dead]);

	// Spawn sound
	useEffect(() => {
		if (hasLocalSnake) {
			playSound(sounds.start_game);
		}
	}, [hasLocalSnake]);

	// Candy eat sound
	useEffect(() => {
		if ((snake?.score ?? 0) > (previousScore ?? 0)) {
			const speed = random.NextNumber(0.87, 1);
			playSound(sounds.whoosh, { volume: 0.6 * volume, speed });
		}
	}, [snake?.score]);

	// Boost sound
	useDebounceEffect(
		() => {
			if (snake) {
				playSound(boosting ? sounds.boost_start : sounds.boost_stop, { volume });
			}
		},
		[boosting],
		{ wait: 0.25, leading: true },
	);

	return <></>;
}


import React from "@rbxts/react";
import { Group } from "client/components/ui/group";
import { RemProvider } from "client/providers/rem-provider";

import { Backdrop } from "./backdrop";
import { Candy } from "./candy";
import { Snakes } from "./snakes";
import { WorldBorder } from "./world-border";
import { WorldSounds } from "./world-sounds";
import { WorldSubject } from "./world-subject";

export function World() {
	return (
		<RemProvider minimumRem={6}>
			<Group>
				<Backdrop />
				<Candy />
				<Snakes />
				<WorldSubject />
				<WorldBorder />
				<WorldSounds />
			</Group>
		</RemProvider>
	);
}


export * from "./use-character";
export * from "./use-continuous-angle";
export * from "./use-defined";
export * from "./use-input-device";
export * from "./use-orientation";
export * from "./use-premium";
export * from "./use-product-price";
export * from "./use-rem";
export * from "./use-seed";
export * from "./use-store";
export * from "@rbxts/react-ripple";


import { useAsync, useEventListener } from "@rbxts/pretty-react-hooks";
import { useState } from "@rbxts/react";
import { Character, promiseCharacter } from "shared/utils/player-utils";

export function useCharacter(player: Player): Character | undefined {
	const [model, setModel] = useState(player.Character);

	const [character] = useAsync(async () => {
		return model && promiseCharacter(model);
	}, [model]);

	useEventListener(player.CharacterAdded, (character) => {
		setModel(character);
	});

	useEventListener(player.CharacterRemoving, () => {
		setModel(undefined);
	});

	return character;
}


import { useMemo, useRef } from "@rbxts/react";
import { subtractRadians } from "shared/utils/math-utils";

/**
 * Returns a continuous angle that is always the shortest distance from the
 * previous angle. Used to prevent angles looping around when they reach
 * 360 degrees.
 */
export function useContinuousAngle(angle: number) {
	const previousAngle = useRef(angle);
	const continuousAngle = useRef(angle);

	useMemo(() => {
		continuousAngle.current += subtractRadians(angle, previousAngle.current);
		previousAngle.current = angle;
	}, [angle]);

	return continuousAngle.current;
}


import { useLatest } from "@rbxts/pretty-react-hooks";

/**
 * Returns true if the two values are equal and the new value is defined.
 * @param a The previous value.
 * @param b The new value.
 * @returns True if the two values are equal and the new value is defined.
 */
function isEqualOrUndefined(a: unknown, b: unknown) {
	return a === b || b === undefined;
}

/**
 * Returns the latest defined value.
 * @param value The value to use.
 * @returns A non-nullable value.
 */
export function useDefined<T>(value: T | undefined, initialValue: T): T;
export function useDefined<T>(value: T, initialValue?: T): T;
export function useDefined<T>(value: T, initialValue?: T) {
	return useLatest(value, isEqualOrUndefined).current ?? initialValue;
}


import { useViewport } from "@rbxts/pretty-react-hooks";
import { useState } from "@rbxts/react";

export function useOrientation() {
	const [orientation, setOrientation] = useState<"landscape" | "portrait">("landscape");

	useViewport((viewport) => {
		setOrientation(viewport.Y > viewport.X ? "portrait" : "landscape");
	});

	return orientation;
}


import { useEventListener } from "@rbxts/pretty-react-hooks";
import { useState } from "@rbxts/react";
import { Players } from "@rbxts/services";

export function usePremium() {
	const [isPremium, setIsPremium] = useState(Players.LocalPlayer.MembershipType === Enum.MembershipType.Premium);

	useEventListener(Players.PlayerMembershipChanged, (player) => {
		if (player === Players.LocalPlayer) {
			setIsPremium(player.MembershipType === Enum.MembershipType.Premium);
		}
	});

	return isPremium;
}


import { useAsync } from "@rbxts/pretty-react-hooks";
import { MarketplaceService } from "@rbxts/services";

export function useProductPrice(productId: number) {
	const [info = "N/A"] = useAsync(() => {
		return Promise.retryWithDelay(
			async () => {
				return MarketplaceService.GetProductInfo(productId, Enum.InfoType.Product).PriceInRobux;
			},
			10,
			5,
		);
	});

	return info;
}


import { useCallback, useContext } from "@rbxts/react";

import { DEFAULT_REM, RemContext } from "../providers/rem-provider";

export interface RemOptions {
	minimum?: number;
	maximum?: number;
}

interface RemFunction {
	(value: number, mode?: RemScaleMode): number;
	(value: UDim2, mode?: RemScaleMode): UDim2;
	(value: UDim, mode?: RemScaleMode): UDim;
	(value: Vector2, mode?: RemScaleMode): Vector2;
}

type RemScaleMode = "pixel" | "unit";

const scaleFunctions = {
	number: (value: number, rem: number): number => {
		return value * rem;
	},

	UDim2: (value: UDim2, rem: number): UDim2 => {
		return new UDim2(value.X.Scale, value.X.Offset * rem, value.Y.Scale, value.Y.Offset * rem);
	},

	UDim: (value: UDim, rem: number): UDim => {
		return new UDim(value.Scale, value.Offset * rem);
	},

	Vector2: (value: Vector2, rem: number): Vector2 => {
		return new Vector2(value.X * rem, value.Y * rem);
	},
};

function useRemContext({ minimum = 0, maximum = math.huge }: RemOptions = {}) {
	const rem = useContext(RemContext);
	return math.clamp(rem, minimum, maximum);
}

export function useRem(options?: RemOptions): RemFunction {
	const rem = useRemContext(options);

	const remFunction: RemFunction = <T>(value: T, mode: RemScaleMode = "unit"): T => {
		const scale = scaleFunctions[typeOf(value) as never] as <T>(value: T, rem: number) => T;

		if (scale) {
			return mode === "unit" ? scale(value, rem) : scale(value, rem / DEFAULT_REM);
		} else {
			return value;
		}
	};

	return useCallback(remFunction, [rem]);
}


import { useMemo } from "@rbxts/react";

/**
 * Generates a random seed unique to this function component.
 */
export function useSeed() {
	return useMemo(() => {
		const random = new Random();
		return random.NextNumber(-256, 256);
	}, []);
}


import { useProducer, UseProducerHook } from "@rbxts/react-reflex";
import { RootStore } from "client/store";

export const useStore: UseProducerHook<RootStore> = useProducer;


import { map, useCamera, useDebounceState, useEventListener } from "@rbxts/pretty-react-hooks";
import React, { createContext, useEffect } from "@rbxts/react";

export interface RemProviderProps extends React.PropsWithChildren {
	baseRem?: number;
	remOverride?: number;
	minimumRem?: number;
	maximumRem?: number;
}

export const DEFAULT_REM = 16;
export const MIN_REM = 8;
const BASE_RESOLUTION = new Vector2(1920, 1020);
const MAX_ASPECT_RATIO = 19 / 9;

export const RemContext = createContext<number>(DEFAULT_REM);

export function RemProvider({
	baseRem = DEFAULT_REM,
	minimumRem = MIN_REM,
	maximumRem = math.huge,
	remOverride,
	children,
}: RemProviderProps) {
	const camera = useCamera();
	const [rem, setRem] = useDebounceState(baseRem, { wait: 0.2, leading: true });

	const update = () => {
		const viewport = camera.ViewportSize;

		if (remOverride !== undefined) {
			return remOverride;
		}

		// wide screens should not scale beyond iPhone aspect ratio
		const resolution = new Vector2(math.min(viewport.X, viewport.Y * MAX_ASPECT_RATIO), viewport.Y);
		const scale = resolution.Magnitude / BASE_RESOLUTION.Magnitude;
		const desktop = resolution.X > resolution.Y || scale >= 1;

		// portrait mode should downscale slower than landscape
		const factor = desktop ? scale : map(scale, 0, 1, 0.25, 1);

		setRem(math.clamp(math.round(baseRem * factor), minimumRem, maximumRem));
	};

	useEventListener(camera.GetPropertyChangedSignal("ViewportSize"), update);

	useEffect(() => {
		update();
	}, [baseRem, minimumRem, maximumRem, remOverride]);

	return <RemContext.Provider value={rem}>{children}</RemContext.Provider>;
}


import React from "@rbxts/react";
import { ReflexProvider } from "@rbxts/react-reflex";
import { store } from "client/store";

import { RemProvider, RemProviderProps } from "./rem-provider";

export function RootProvider({ baseRem, remOverride, children }: RemProviderProps) {
	return (
		<ReflexProvider producer={store}>
			<RemProvider baseRem={baseRem} remOverride={remOverride}>
				{children}
			</RemProvider>
		</ReflexProvider>
	);
}


import { StarterGui } from "@rbxts/services";
import { remotes } from "shared/remotes";

async function setCore() {
	const resetBindable = new Instance("BindableEvent");

	resetBindable.Event.Connect(() => {
		remotes.snake.kill.fire();
	});

	StarterGui.SetCore("ResetButtonCallback", resetBindable);
}

Promise.retryWithDelay(setCore, 10, 5);


import { createSelector } from "@rbxts/reflex";

import { RootState } from "..";

export const selectAlerts = (state: RootState) => {
	return state.alert.alerts;
};

export const selectAlertsVisible = createSelector(selectAlerts, (alerts) => {
	return alerts.filter((alert) => alert.visible);
});

export const selectAlertIndex = (id: number) => {
	return createSelector(selectAlertsVisible, (alerts) => {
		return alerts.findIndex((alert) => alert.id === id);
	});
};


import { createProducer } from "@rbxts/reflex";

export interface AlertState {
	readonly alerts: readonly Alert[];
}

export interface Alert {
	readonly id: number;
	readonly scope?: AlertScope;
	readonly emoji: string;
	readonly message: string;
	readonly color: Color3;
	readonly colorSecondary?: Color3;
	readonly colorMessage?: Color3;
	readonly duration: number;
	readonly visible: boolean;
	readonly sound?: string;
}

export type AlertScope = "ranking" | "money";

const initialState: AlertState = {
	alerts: [],
};

export const alertSlice = createProducer(initialState, {
	addAlert: (state, alert: Alert) => ({
		...state,
		alerts: [alert, ...state.alerts],
	}),

	removeAlert: (state, id: number) => ({
		...state,
		alerts: state.alerts.filter((alert) => alert.id !== id),
	}),

	setAlertVisible: (state, id: number, visible: boolean) => ({
		...state,
		alerts: state.alerts.map((alert) => (alert.id === id ? { ...alert, visible } : alert)),
	}),
});


import { combineProducers, InferState } from "@rbxts/reflex";
import { slices } from "shared/store";
import { profilerMiddleware } from "shared/store/middleware/profiler";

import { alertSlice } from "./alert";
import { menuSlice } from "./menu";
import { receiverMiddleware } from "./middleware/receiver";
import { worldSlice } from "./world";

export type RootStore = typeof store;

export type RootState = InferState<RootStore>;

export function createStore() {
	const store = combineProducers({
		...slices,
		alert: alertSlice,
		menu: menuSlice,
		world: worldSlice,
	});

	store.applyMiddleware(profilerMiddleware, receiverMiddleware());

	return store;
}

export const store = createStore();


import { createProducer } from "@rbxts/reflex";
import { RANDOM_SKIN } from "shared/store/saves";

import { getMenuDirection } from "./menu-utils";

export interface MenuState {
	readonly page: MenuPage;
	readonly open: boolean;
	readonly music: boolean;
	readonly transition: {
		readonly direction: "left" | "right";
		readonly counter: number;
	};
	readonly skins: {
		readonly current: string;
	};
}

export type MenuPage = "support" | "home" | "skins";

const initialState: MenuState = {
	page: "home",
	open: true,
	music: true,
	transition: {
		direction: "left",
		counter: 0,
	},
	skins: {
		current: RANDOM_SKIN,
	},
};

export const menuSlice = createProducer(initialState, {
	setMenuPage: (state, page: MenuPage) => ({
		...state,
		page,
		transition: {
			direction: getMenuDirection(state.page, page),
			counter: state.transition.counter + 1,
		},
	}),

	setMenuOpen: (state, open: boolean) => ({
		...state,
		open,
	}),

	setMenuSkin: (state, skin: string) => ({
		...state,
		skins: {
			...state.skins,
			current: skin,
		},
	}),

	setMenuMusic: (state, music: boolean) => ({
		...state,
		music,
	}),
});


import { createBroadcastReceiver, ProducerMiddleware } from "@rbxts/reflex";
import { IS_EDIT } from "shared/constants/core";
import { remotes } from "shared/remotes";
import { deserializeState } from "shared/serdes";

export function receiverMiddleware(): ProducerMiddleware {
	if (IS_EDIT) {
		return () => (dispatch) => dispatch;
	}

	const receiver = createBroadcastReceiver({
		start: () => {
			remotes.store.start.fire();
		},
	});

	remotes.store.dispatch.connect((actions) => {
		receiver.dispatch(actions);
	});

	remotes.store.hydrate.connect((state) => {
		receiver.hydrate(deserializeState(state));
	});

	return receiver.middleware;
}


import { createProducer } from "@rbxts/reflex";

export interface WorldState {
	readonly subject: string;
	readonly spectating: string;
	readonly inputAngle: number;
}

const initialState: WorldState = {
	subject: "",
	spectating: "",
	inputAngle: 0,
};

export const worldSlice = createProducer(initialState, {
	setWorldSubject: (state, subject: string) => ({
		...state,
		subject,
	}),

	setWorldSpectating: (state, spectating: string) => ({
		...state,
		spectating,
	}),

	setWorldInputAngle: (state, inputAngle: number) => ({
		...state,
		inputAngle,
	}),
});


import "client/app/react-config";

import { hoarcekat } from "@rbxts/pretty-react-hooks";
import React from "@rbxts/react";
import { sendAlert } from "client/alerts";
import { Alerts } from "client/components/alerts";
import { Menu } from "client/components/menu";
import { InputCapture } from "client/components/ui/input-capture";
import { Backdrop } from "client/components/world/backdrop";
import { RootProvider } from "client/providers/root-provider";
import { palette } from "shared/constants/palette";

export = hoarcekat(() => {
	const modes = ["info", "success", "warning", "error", "awesome"] as const;

	const alert = () => {
		const mode = modes[math.random(0, modes.size() - 1)];

		switch (mode) {
			case "info":
				sendAlert({ emoji: "ℹ", color: palette.blue, message: "This is an info alert." });
				break;
			case "success":
				sendAlert({ emoji: "✅", color: palette.green, message: "This is a success alert." });
				break;
			case "warning":
				sendAlert({ emoji: "⚠", color: palette.yellow, message: "This is a warning alert." });
				break;
			case "error":
				sendAlert({ emoji: "🚨", color: palette.red, message: "This is an error alert." });
				break;
			case "awesome":
				sendAlert({
					emoji: "🎉",
					color: palette.mauve,
					colorSecondary: palette.blue,
					message: "This is an awesome alert.",
				});
				break;
		}
	};

	return (
		<RootProvider>
			<Backdrop />
			<Alerts />
			<Menu />
			<InputCapture
				onInputBegan={(_, input) => {
					if (input.KeyCode === Enum.KeyCode.F) {
						alert();
					}
				}}
			/>
		</RootProvider>
	);
});


import "client/app/react-config";

import { hoarcekat, useInterval } from "@rbxts/pretty-react-hooks";
import React, { useEffect } from "@rbxts/react";
import { useSelector } from "@rbxts/react-reflex";
import { Players } from "@rbxts/services";
import { Controller } from "client/components/controller";
import { Frame } from "client/components/ui/frame";
import { Group } from "client/components/ui/group";
import { Text } from "client/components/ui/text";
import { World } from "client/components/world/world";
import { useRem } from "client/hooks";
import { RootProvider } from "client/providers/root-provider";
import { store } from "client/store";
import { selectWorldCamera } from "client/store/world";
import { USER_NAME, WORLD_TICK } from "shared/constants/core";
import { palette } from "shared/constants/palette";
import { getRandomBaseSnakeSkin } from "shared/constants/skins";
import { describeSnakeFromScore, selectLocalSnake } from "shared/store/snakes";
import { createScheduler } from "shared/utils/scheduler";

import { useMockRemotes } from "../utils/use-mock-remotes";

const START_SIZE = 0;
const SIZE_INCREMENT = 100;
const DEBUG_SIZES = [0, 500, 1000, 2500, 5000, 10000, 20000, 40000, 80000, 160000];

function Debugger() {
	const rem = useRem();
	const snake = useSelector(selectLocalSnake);
	const world = useSelector(selectWorldCamera);

	if (!snake) {
		return <></>;
	}

	const description = describeSnakeFromScore(snake.score);

	return (
		<>
			<Text
				text={`Score: ${snake.score}`}
				textColor={palette.text}
				textXAlignment="Left"
				textYAlignment="Bottom"
				position={new UDim2(0, rem(2), 1, rem(-2))}
			/>

			<Text
				text={`Tracers: ${math.floor(description.length)}`}
				textColor={palette.text}
				textXAlignment="Left"
				textYAlignment="Bottom"
				position={new UDim2(0, rem(2), 1, rem(-4))}
			/>

			<Group size={new UDim2(1, 0, 0.5, 0)}>
				<uilistlayout
					FillDirection="Horizontal"
					VerticalAlignment="Center"
					HorizontalAlignment="Center"
					SortOrder="LayoutOrder"
				/>

				{DEBUG_SIZES.map((score, index) => {
					const description = describeSnakeFromScore(score);
					const diameter = rem(world.scale * description.radius * 2);

					return (
						<Frame
							key={`debug-${index}`}
							size={new UDim2(0, diameter, 0, diameter)}
							backgroundColor={palette.red}
							cornerRadius={new UDim(1, 0)}
							layoutOrder={index}
						>
							<Text
								text={`${score}\nl${math.floor(description.length)}\nd${string.format(
									"%.2f",
									description.radius,
								)}`}
								textColor={palette.crust}
								textScaled
								size={new UDim2(1, 0, 1, 0)}
							>
								<uipadding PaddingBottom={new UDim(0, rem(0.5))} PaddingTop={new UDim(0, rem(0.5))} />
								<uitextsizeconstraint MaxTextSize={rem(2)} />
							</Text>
						</Frame>
					);
				})}
			</Group>
		</>
	);
}

export = hoarcekat(() => {
	useMockRemotes();

	useEffect(() => {
		store.addSnake(USER_NAME, {
			name: Players.LocalPlayer.DisplayName,
			skin: getRandomBaseSnakeSkin().id,
			score: START_SIZE,
		});

		return createScheduler({
			name: "world-tick",
			tick: WORLD_TICK,
			onTick: store.snakeTick,
		});
	}, []);

	useInterval(() => {
		store.incrementSnakeScore(USER_NAME, SIZE_INCREMENT);
	}, 0.5);

	return (
		<RootProvider>
			<World />
			<Controller />
			<Debugger />
		</RootProvider>
	);
});


import "client/app/react-config";

import { hoarcekat } from "@rbxts/pretty-react-hooks";
import React, { useEffect } from "@rbxts/react";
import { ErrorHandler } from "client/components/error-handler";
import { RootProvider } from "client/providers/root-provider";

function BadComponent() {
	useEffect(() => {
		throw "Bad component!";
	}, []);

	return <frame />;
}

export = hoarcekat(() => (
	<RootProvider>
		<ErrorHandler>
			<BadComponent />
		</ErrorHandler>
	</RootProvider>
));


import "client/app/react-config";

import { hoarcekat, useInterval } from "@rbxts/pretty-react-hooks";
import React, { useEffect } from "@rbxts/react";
import { Controller } from "client/components/controller";
import { Game } from "client/components/game";
import { World } from "client/components/world";
import { RootProvider } from "client/providers/root-provider";
import { store } from "client/store";
import { USER_NAME, WORLD_BOUNDS, WORLD_TICK } from "shared/constants/core";
import { getRandomAccent } from "shared/constants/palette";
import { getRandomBaseSnakeSkin } from "shared/constants/skins";
import { CandyType } from "shared/store/candy";
import { fillArray } from "shared/utils/object-utils";
import { createScheduler } from "shared/utils/scheduler";

import { useMockRemotes } from "../utils/use-mock-remotes";

const IDS = [USER_NAME, ...fillArray(10, (index) => `${index}`)];

export = hoarcekat(() => {
	useMockRemotes();

	useEffect(() => {
		for (const id of IDS) {
			store.addSnake(id, {
				name: id,
				head:
					id === USER_NAME
						? Vector2.zero
						: new Vector2(
								math.random(-WORLD_BOUNDS, WORLD_BOUNDS),
								math.random(-WORLD_BOUNDS, WORLD_BOUNDS),
							),
				skin: getRandomBaseSnakeSkin().id,
				score: math.random(0, 5000),
			});
		}

		store.populateCandy(
			fillArray(512, (index) => ({
				id: `test-${index}`,
				type: CandyType.Default,
				position: new Vector2(
					(math.random() * 2 - 1) * WORLD_BOUNDS * 0.2,
					(math.random() * 2 - 1) * WORLD_BOUNDS * 0.2,
				),
				size: math.random(1, 10),
				color: getRandomAccent(),
			})),
		);

		return createScheduler({
			name: "world-tick",
			tick: WORLD_TICK,
			onTick: store.snakeTick,
		});
	}, []);

	useInterval(() => {
		for (const id of IDS) {
			if (id !== USER_NAME) {
				store.turnSnake(id, math.random() * 2 * math.pi);
			}
		}
	}, 1.5);

	return (
		<RootProvider>
			<World />
			<Game />
			<Controller />
		</RootProvider>
	);
});


import "client/app/react-config";

import { hoarcekat } from "@rbxts/pretty-react-hooks";
import React from "@rbxts/react";
import { Menu } from "client/components/menu";
import { InputCapture } from "client/components/ui/input-capture";
import { World } from "client/components/world";
import { RootProvider } from "client/providers/root-provider";
import { store } from "client/store";
import { USER_NAME } from "shared/constants/core";
import { selectSnakeById } from "shared/store/snakes";

import { useMockRemotes } from "../utils/use-mock-remotes";

export = hoarcekat(() => {
	useMockRemotes();

	const toggle = () => {
		const snake = store.getState(selectSnakeById(USER_NAME));

		if (snake) {
			store.removeSnake(USER_NAME);
		} else {
			store.addSnake(USER_NAME);
		}
	};

	return (
		<RootProvider>
			<InputCapture
				onInputBegan={(rbx, input) => {
					if (input.KeyCode === Enum.KeyCode.F) {
						toggle();
					}
				}}
			/>
			<World />
			<Menu />
		</RootProvider>
	);
});


import "client/app/react-config";

import { hoarcekat, useMountEffect } from "@rbxts/pretty-react-hooks";
import React from "@rbxts/react";
import { Alerts } from "client/components/alerts";
import { Menu } from "client/components/menu";
import { World } from "client/components/world";
import { RootProvider } from "client/providers/root-provider";
import { store } from "client/store";
import { USER_NAME } from "shared/constants/core";
import { defaultPlayerSave } from "shared/store/saves";

import { useMockRemotes } from "../utils/use-mock-remotes";

export = hoarcekat(() => {
	useMockRemotes();

	useMountEffect(() => {
		store.setMenuPage("skins");
		store.setPlayerSave(USER_NAME, defaultPlayerSave);
	});

	return (
		<RootProvider>
			<World />
			<Menu />
			<Alerts />
		</RootProvider>
	);
});


import "client/app/react-config";

import { hoarcekat, useMountEffect } from "@rbxts/pretty-react-hooks";
import React from "@rbxts/react";
import { Menu } from "client/components/menu";
import { Stats } from "client/components/stats";
import { Backdrop } from "client/components/world/backdrop";
import { RootProvider } from "client/providers/root-provider";
import { store } from "client/store";
import { USER_NAME } from "shared/constants/core";
import { defaultPlayerSave } from "shared/store/saves";

export = hoarcekat(() => {
	useMountEffect(() => {
		store.setPlayerSave(USER_NAME, {
			...defaultPlayerSave,
			balance: 1000,
		});
	});

	return (
		<RootProvider>
			<Backdrop />
			<Menu />
			<Stats />
		</RootProvider>
	);
});


import "client/app/react-config";

import { hoarcekat, useMountEffect } from "@rbxts/pretty-react-hooks";
import React from "@rbxts/react";
import { Menu } from "client/components/menu";
import { World } from "client/components/world";
import { RootProvider } from "client/providers/root-provider";
import { store } from "client/store";
import { USER_NAME } from "shared/constants/core";
import { defaultPlayerSave } from "shared/store/saves";

import { useMockRemotes } from "../utils/use-mock-remotes";

export = hoarcekat(() => {
	useMockRemotes();

	useMountEffect(() => {
		store.setMenuPage("support");
		store.setPlayerSave(USER_NAME, {
			...defaultPlayerSave,
			balance: 0,
		});
	});

	return (
		<RootProvider>
			<World />
			<Menu />
		</RootProvider>
	);
});


import "client/app/react-config";

import { hoarcekat, useInterval } from "@rbxts/pretty-react-hooks";
import React, { useEffect } from "@rbxts/react";
import { Controller } from "client/components/controller";
import { World } from "client/components/world/world";
import { RootProvider } from "client/providers/root-provider";
import { store } from "client/store";
import { USER_NAME, WORLD_TICK } from "shared/constants/core";
import { getRandomAccent } from "shared/constants/palette";
import { getRandomBaseSnakeSkin } from "shared/constants/skins";
import { CandyType } from "shared/store/candy";
import { fillArray } from "shared/utils/object-utils";
import { createScheduler } from "shared/utils/scheduler";

import { useMockRemotes } from "../utils/use-mock-remotes";

const IDS = [USER_NAME, ...fillArray(10, (index) => `${index}`)];

export = hoarcekat(() => {
	useMockRemotes();

	useEffect(() => {
		for (const id of IDS) {
			store.addSnake(id, {
				name: id,
				head: new Vector2(math.random(-10, 10), math.random(-10, 10)),
				skin: getRandomBaseSnakeSkin().id,
				score: math.random(0, 8000),
			});
		}

		store.populateCandy(
			fillArray(50, (index) => ({
				id: `${index}`,
				position: new Vector2(math.random(-50, 50), math.random(-25, 25)),
				size: math.random(1, 50),
				color: getRandomAccent(),
				type: CandyType.Default,
			})),
		);

		return createScheduler({
			name: "world-tick",
			tick: WORLD_TICK,
			onTick: store.snakeTick,
		});
	}, []);

	useInterval(() => {
		for (const id of IDS) {
			if (id !== USER_NAME) {
				store.turnSnake(id, math.random() * 2 * math.pi);
			}
		}
	}, 1.5);

	return (
		<RootProvider>
			<World />
			<Controller />
		</RootProvider>
	);
});


import { useEffect } from "@rbxts/react";
import { store } from "client/store";
import { USER_NAME } from "shared/constants/core";
import { getSnakeSkin } from "shared/constants/skins";
import { remotes } from "shared/remotes";
import { selectPlayerBalance } from "shared/store/saves";

export function useMockRemotes() {
	useEffect(() => {
		const connections = [
			remotes.snake.move.test.onFire((angle) => {
				store.turnSnake(USER_NAME, angle);
			}),

			remotes.snake.boost.test.onFire((boost) => {
				store.boostSnake(USER_NAME, boost);
			}),

			remotes.snake.spawn.test.onFire(() => {
				store.addSnake(USER_NAME);
			}),

			remotes.save.setSkin.test.onFire((skin) => {
				store.setPlayerSkin(USER_NAME, skin);
			}),

			remotes.save.buySkin.test.onFire((skinId) => {
				const balance = store.getState(selectPlayerBalance(USER_NAME)) ?? 0;
				const skin = getSnakeSkin(skinId);

				if (balance >= skin.price) {
					store.givePlayerSkin(USER_NAME, skinId);
					store.givePlayerBalance(USER_NAME, -skin.price);
				}
			}),
		];

		return () => {
			connections.forEach((connection) => connection());
		};
	}, []);
}


import Object from "@rbxts/object-utils";
import { setInterval } from "@rbxts/set-timeout";
import { store } from "server/store";
import { candyGrid, snakeGrid } from "server/world";
import { getCandy, getSnake } from "server/world/utils";
import { WORLD_BOUNDS } from "shared/constants/core";
import { CandyType } from "shared/store/candy";
import { describeSnakeFromScore, SnakeEntity } from "shared/store/snakes";
import { map } from "shared/utils/math-utils";

export enum BehaviorMode {
	Idle,
	Scavenging,
}

const BEHAVIORS = Object.values(BehaviorMode);

export class BotBehavior {
	public readonly id: string;
	private readonly seed: number;
	private readonly cleanup: () => void;

	constructor(id: string) {
		this.id = id;
		this.seed = math.random() * 255;

		// Updates once per
		this.cleanup = setInterval(() => {
			this.update();
		}, 1);
	}

	public destroy() {
		this.cleanup();
	}

	private idle(snake: SnakeEntity) {
		const head = snake.head;
		if (WORLD_BOUNDS - head.Magnitude <= 20) {
			const angle = math.atan2(head.Y, head.X) + math.rad(180);
			store.turnSnake(this.id, angle);
			return;
		}

		const range = math.random() > 0.2 ? 20 : 180;
		// Max turn of 20 degrees away from current angle
		store.turnSnake(this.id, snake.angle + math.rad(math.random(-range, range)));
	}

	private scavenge(snake: SnakeEntity) {
		const head = snake.head;

		let target = candyGrid.nearest(head, 15, (point) => {
			const candy = getCandy(point.metadata.id);
			return candy !== undefined && !candy.eatenAt && candy.type === CandyType.Loot;
		});

		if (!target) {
			target = candyGrid.nearest(head, 15, (point) => {
				const candy = getCandy(point.metadata.id);
				return candy !== undefined && !candy.eatenAt;
			});
		}

		const candy = target && getCandy(target.metadata.id);
		if (!candy) {
			this.idle(snake);
			return;
		}

		const angle = math.atan2(candy.position.Y - head.Y, candy.position.X - head.X);
		store.turnSnake(this.id, angle);
	}

	private flee(snake: SnakeEntity, direction: Vector2) {
		// Go 180 (plus or minus some variance) degrees in opposite direction
		const angle = math.atan2(direction.Y, direction.X) + math.rad(180 + math.random(-10, 10));
		store.turnSnake(snake.id, angle);
	}

	private update() {
		const snake = getSnake(this.id);
		const behavior = this.getBehavior();

		if (!snake) {
			return;
		}

		const nearbyEnemy = this.directionToNearestEnemy(snake);
		if (nearbyEnemy) {
			this.flee(snake, nearbyEnemy);
			return;
		}

		switch (behavior) {
			case BehaviorMode.Idle: {
				this.idle(snake);
				break;
			}
			case BehaviorMode.Scavenging: {
				this.scavenge(snake);
				break;
			}
		}
	}

	private directionToNearestEnemy(snake: SnakeEntity): Vector2 | undefined {
		const radius = describeSnakeFromScore(snake.score).radius;

		const nearest = snakeGrid.nearest(snake.head, radius * 10 + 3, (hit) => {
			const enemy = getSnake(hit.metadata.id);
			return enemy !== undefined && !enemy.dead && enemy.id !== snake.id;
		});

		const enemy = nearest && getSnake(nearest.metadata.id);

		if (!enemy) {
			return;
		}

		const enemyRadius = describeSnakeFromScore(enemy.score).radius;
		const direction = nearest.position.sub(snake.head);
		const distance = direction.Magnitude;

		if (distance <= 5 * (radius + enemyRadius)) {
			return direction.Unit;
		}
	}

	private getBehavior() {
		const noise = math.noise(this.seed, time() / 10);
		const index = math.round(map(noise, -0.5, 0.5, 0, BEHAVIORS.size() - 1));

		return BEHAVIORS[index] ?? BehaviorMode.Idle;
	}
}


import { Players, RunService } from "@rbxts/services";

import { getTextChatCommands } from "./utils";

const ADMINS = new ReadonlySet([
	48203430, // @LITTENSY
	96035249, // @NeoInversion
]);

export async function createCommand(alias: string, handler: (player: Player, argument: string) => void) {
	const container = await getTextChatCommands();
	const command = new Instance("TextChatCommand");

	command.Triggered.Connect((origin, unfilteredText) => {
		const player = Players.GetPlayerByUserId(origin.UserId);

		if (player && (ADMINS.has(origin.UserId) || RunService.IsStudio())) {
			const argument = unfilteredText.sub(2 + alias.size());
			handler(player, argument);
		}
	});

	command.Name = `GameCommand_${alias}`;
	command.PrimaryAlias = alias;
	command.Parent = container;
}


import { TextChatService } from "@rbxts/services";
import { promiseTree } from "@rbxts/validate-tree";

interface EnhancedTextChatService extends TextChatService {
	TextChannels: Folder & {
		RBXGeneral: TextChannel;
		RBXSystem: TextChannel;
	};
	TextChatCommands: Folder;
}

const textChatServiceSchema = {
	$className: "TextChatService",
	TextChannels: {
		$className: "Folder",
		RBXGeneral: "TextChannel",
		RBXSystem: "TextChannel",
	},
	TextChatCommands: "Folder",
} as const;

async function promiseTextChatService(): Promise<EnhancedTextChatService> {
	return promiseTree(TextChatService, textChatServiceSchema);
}

export async function getTextChannels() {
	const { TextChannels } = await promiseTextChatService();
	return TextChannels;
}

export async function getTextChatCommands() {
	const { TextChatCommands } = await promiseTextChatService();
	return TextChatCommands;
}


import { createCollection } from "@rbxts/lapis";
import { Players } from "@rbxts/services";
import { store } from "server/store";
import { sounds } from "shared/assets";
import { palette } from "shared/constants/palette";
import { remotes } from "shared/remotes";
import { defaultPlayerSave, playerSaveSchema, selectPlayerSave } from "shared/store/saves";
import { onPlayerAdded, promisePlayerDisconnected } from "shared/utils/player-utils";

const collection = createCollection("players", {
	defaultData: defaultPlayerSave,
	validate: playerSaveSchema,
});

export async function initSaveService() {
	onPlayerAdded(loadPlayerSave);
}

async function loadPlayerSave(player: Player) {
	try {
		const document = await collection.load(`${player.UserId}`);

		if (!player.IsDescendantOf(Players)) {
			return document.close();
		}

		const disconnect = store.subscribe(selectPlayerSave(player.Name), (newSave) => {
			if (newSave) {
				document.write(newSave);
			}
		});

		promisePlayerDisconnected(player).then(() => {
			store.deletePlayerSave(player.Name);
			disconnect();
			document.close();
		});

		store.setPlayerSave(player.Name, document.read());
	} catch (e) {
		warn(`Failed to load data for ${player.Name}: ${e}`);
		fallbackPlayerSave(player);
	}
}

async function fallbackPlayerSave(player: Player) {
	promisePlayerDisconnected(player).then(() => {
		store.deletePlayerSave(player.Name);
	});

	store.setPlayerSave(player.Name, defaultPlayerSave);

	remotes.client.alert.fire(player, {
		emoji: "🚨",
		color: palette.red,
		message: "Roblox may be having issues with your save data. Sorry for the inconvenience!",
		duration: 10,
		sound: sounds.alert_bad,
	});
}


import { MarketplaceService, Players } from "@rbxts/services";

type ProductHandler = (player: Player) => void;

const productHandlers = new Map<number, ProductHandler>();

export async function initProcessReceiptService() {
	MarketplaceService.ProcessReceipt = (receipt) => {
		const player = Players.GetPlayerByUserId(receipt.PlayerId);
		const handler = productHandlers.get(receipt.ProductId);

		if (!player || !handler) {
			return Enum.ProductPurchaseDecision.NotProcessedYet;
		}

		const [success, message] = pcall(handler, player);

		if (!success) {
			warn(message);
			return Enum.ProductPurchaseDecision.NotProcessedYet;
		}

		return Enum.ProductPurchaseDecision.PurchaseGranted;
	};
}

export function createProduct(id: number, handler: ProductHandler) {
	productHandlers.set(id, handler);
}


import { BadgeService } from "@rbxts/services";
import { store } from "server/store";
import {
	identifyMilestone,
	ScoreMilestone,
	selectMilestoneRanking,
	selectMilestones,
	selectMilestoneScore,
} from "server/store/milestones";
import { Badge } from "shared/assets";
import { getPlayerByName } from "shared/utils/player-utils";

import { shouldGrantBadge } from "../utils";

const RANKING_BADGES: { [K in number]?: Badge } = {
	1: Badge.FIRST_PLACE,
	2: Badge.SECOND_PLACE,
	3: Badge.THIRD_PLACE,
};

const SCORE_BADGES: { [K in ScoreMilestone]?: Badge } = {
	25_000: Badge.SCORE_25000,
	50_000: Badge.SCORE_50000,
	100_000: Badge.SCORE_100000,
};

export async function initBadgeService() {
	store.observe(selectMilestones, identifyMilestone, (_, id) => {
		return observeMilestone(id);
	});
}

function observeMilestone(id: string) {
	const unsubscribeRanking = store.subscribe(selectMilestoneRanking(id), (ranking) => {
		if (ranking !== undefined && ranking in RANKING_BADGES) {
			tryGrantBadge(id, RANKING_BADGES[ranking]!);
		}
	});

	const unsubscribeScore = store.subscribe(selectMilestoneScore(id), (score) => {
		if (score !== undefined && score in SCORE_BADGES) {
			tryGrantBadge(id, SCORE_BADGES[score]!);
		}
	});

	return () => {
		unsubscribeRanking();
		unsubscribeScore();
	};
}

async function tryGrantBadge(playerName: string, badgeId: number) {
	const player = getPlayerByName(playerName);

	if (player && shouldGrantBadge()) {
		try {
			BadgeService.AwardBadge(player.UserId, badgeId);
		} catch (e) {
			warn(`Failed to grant badge ${Badge[badgeId]} to ${player}: ${e}`);
		}
	}
}


import { setInterval } from "@rbxts/set-timeout";
import { store } from "server/store";
import {
	identifyMilestone,
	ScoreMilestone,
	selectMilestoneLastKilled,
	selectMilestoneRanking,
	selectMilestones,
	selectMilestoneScore,
} from "server/store/milestones";
import { getSnake } from "server/world";
import { sounds } from "shared/assets";
import { palette } from "shared/constants/palette";
import { remotes } from "shared/remotes";
import { describeSnakeFromScore, selectSnakeRanking } from "shared/store/snakes";
import { getPlayerByName } from "shared/utils/player-utils";

import { grantMoney, shouldGrantReward } from "../utils";

const SCORE_REWARDS: { readonly [K in ScoreMilestone]: number } = {
	1_000: 20,
	5_000: 50,
	10_000: 100,
	25_000: 250,
	50_000: 500,
	100_000: 1_000,
	250_000: 2_500,
	500_000: 5_000,
	1_000_000: 10_000,
};

const RANK_REWARDS: { readonly [ranking: number]: number | undefined } = {
	1: 100,
	2: 75,
	3: 50,
};

const RANK_REWARDS_PASSIVE: { readonly [ranking: number]: number | undefined } = {
	1: 20,
	2: 10,
	3: 5,
};

export async function initRewardService() {
	store.observe(selectMilestones, identifyMilestone, (_, id) => {
		return observeMilestone(id);
	});
}

function observeMilestone(id: string) {
	// When the player hits a new top ranking they haven't hit
	// during their current life, grant them a reward
	const unsubscribeRanking = store.subscribe(selectMilestoneRanking(id), (ranking = 0) => {
		const reward = RANK_REWARDS[ranking];

		if (reward !== undefined && shouldGrantReward()) {
			grantMoneyReward(id, reward, `making <font color="#fff">top ${ranking}</font>`);
		}
	});

	// When the player hits a new score milestone they haven't hit
	// during their current life, grant them a reward
	const unsubscribeScore = store.subscribe(selectMilestoneScore(id), (score) => {
		const reward = score && SCORE_REWARDS[score];

		if (reward !== undefined && shouldGrantReward()) {
			grantMoneyReward(id, reward, `hitting a score of <font color="#fff">${score}</font>`);
		}
	});

	// When the player kills a snake, grant them a reward based on the
	// length of the snake they killed
	const unsubscribeKill = store.observeWhile(selectMilestoneLastKilled(id), (enemyId) => {
		const enemy = getSnake(enemyId);

		if (enemy && shouldGrantReward()) {
			const length = describeSnakeFromScore(enemy.score).length;
			const bounty = math.ceil(length / 3);
			grantMoneyReward(id, bounty, `eliminating <font color="#fff">${enemy.name}</font>`, true);
		}

		store.clearMilestoneKillScore(id);
	});

	// While the player is in the top 3, grant them a reward every minute
	// as long as they stay in the top 3
	const unsubscribePassive = store.observeWhile(
		selectSnakeRanking(id),
		(rank = 4) => rank <= 3,
		() => {
			return setInterval(() => {
				const rank = store.getState(selectSnakeRanking(id)) ?? 0;
				const reward = RANK_REWARDS_PASSIVE[rank];

				if (reward !== undefined && shouldGrantReward()) {
					grantMoneyReward(id, reward, `staying in the <font color="#fff">top ${rank}</font>`);
				}
			}, 180);
		},
	);

	return () => {
		unsubscribeRanking();
		unsubscribeScore();
		unsubscribeKill();
		unsubscribePassive();
	};
}

function grantMoneyReward(id: string, amount: number, reason: string, immediate = false) {
	const player = getPlayerByName(id);

	if (!player) {
		return;
	}

	amount = grantMoney(player, amount);

	// Delay the alert so that it doesn't appear at the same time as
	// other alerts
	Promise.delay(immediate ? 0 : 0.5).then(() => {
		remotes.client.alert.fire(player, {
			scope: "money",
			emoji: "💵",
			color: palette.green,
			message: `You got <font color="#fff">$${amount}</font> for ${reason}!`,
			sound: sounds.alert_money,
		});
	});
}


import { combineProducers, InferState } from "@rbxts/reflex";
import { slices } from "shared/store";
import { profilerMiddleware } from "shared/store/middleware/profiler";

import { broadcasterMiddleware } from "./middleware/broadcaster";
import { milestoneSlice } from "./milestones";

export type RootState = InferState<typeof store>;

export function createStore() {
	const store = combineProducers({
		...slices,
		milestones: milestoneSlice,
	});

	store.applyMiddleware(profilerMiddleware, broadcasterMiddleware());

	return store;
}

export const store = createStore();


import { createBroadcaster, ProducerMiddleware } from "@rbxts/reflex";
import { Players } from "@rbxts/services";
import { IS_EDIT, WORLD_TICK } from "shared/constants/core";
import { remotes } from "shared/remotes";
import { serializeState, SharedStateSerialized } from "shared/serdes";
import { SharedState, slices } from "shared/store";

export function broadcasterMiddleware(): ProducerMiddleware {
	if (IS_EDIT) {
		return () => (dispatch) => dispatch;
	}

	const hydrated = new Set<number>();

	const broadcaster = createBroadcaster({
		producers: slices,
		dispatchRate: WORLD_TICK,
		hydrateRate: 60,
		dispatch: (player, actions) => {
			remotes.store.dispatch.fire(player, actions);
		},
		hydrate: (player, state) => {
			remotes.store.hydrate.fire(player, state as unknown as SharedStateSerialized);
		},
		beforeHydrate: (player, state) => {
			const isInitialHydrate = !hydrated.has(player.UserId);
			const serialized = serializeState(state, isInitialHydrate) as unknown as SharedState;

			if (isInitialHydrate) {
				hydrated.add(player.UserId);
				return serialized;
			}

			// exclude candy to reduce network traffic
			return {
				...serialized,
				candy: undefined,
			};
		},
	});

	remotes.store.start.connect((player) => {
		broadcaster.start(player);
	});

	Players.PlayerRemoving.Connect((player) => {
		hydrated.delete(player.UserId);
	});

	return broadcaster.middleware;
}


import { createProducer } from "@rbxts/reflex";
import { mapProperty } from "shared/utils/object-utils";

export type MilestoneState = {
	readonly [K in string]?: MilestoneEntity;
};

export interface MilestoneEntity {
	readonly topScore?: ScoreMilestone;
	readonly topRank: number;
	readonly lastKilled?: string;
}

export type ScoreMilestone = (typeof SCORE_MILESTONES)[number];

export const SCORE_MILESTONES = [1_000, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000] as const;

const SCORE_MILESTONES_REVERSE = SCORE_MILESTONES.reduce<ScoreMilestone[]>((acc, score, index) => {
	acc[SCORE_MILESTONES.size() - index] = score;
	return acc;
}, []);

const defaultEntity: MilestoneEntity = {
	topRank: 4,
};

const initialState: MilestoneState = {};

export const milestoneSlice = createProducer(initialState, {
	addMilestone: (state, playerId: string) => ({
		...state,
		[playerId]: { ...defaultEntity },
	}),

	removeMilestone: (state, playerId: string) => ({
		...state,
		[playerId]: undefined,
	}),

	clearMilestoneKillScore: (state, playerId: string) => {
		return mapProperty(state, playerId, (milestone) => ({
			...milestone,
			lastKilled: undefined,
		}));
	},

	setMilestoneRank: (state, playerId: string, ranking: number) => {
		return mapProperty(state, playerId, (milestone) => ({
			...milestone,
			topRank: math.min(ranking, milestone.topRank),
		}));
	},

	setMilestoneScore: (state, playerId: string, score: number) => {
		return mapProperty(state, playerId, (milestone) => {
			const nextMilestone = math.max(
				SCORE_MILESTONES_REVERSE.find((milestone) => score >= milestone) || 0,
				milestone.topScore || 0,
			);

			if (nextMilestone === 0) {
				return milestone;
			}

			return {
				...milestone,
				topScore: nextMilestone as ScoreMilestone,
			};
		});
	},

	playerKilledSnake: (state, playerId: string, lastKilled: string) => {
		return mapProperty(state, playerId, (milestone) => ({
			...milestone,
			lastKilled,
		}));
	},
});