diff --git a/code/__DEFINES/icon_smoothing.dm b/code/__DEFINES/icon_smoothing.dm
index c456b550ae9..63e090d17d1 100644
--- a/code/__DEFINES/icon_smoothing.dm
+++ b/code/__DEFINES/icon_smoothing.dm
@@ -216,6 +216,8 @@ DEFINE_BITFIELD(smoothing_junction, list(
#define SMOOTH_GROUP_SHIELD_GEN_WALL S_OBJ(79) // /obj/machinery/shieldwall
#define SMOOTH_GROUP_VIS_BLOCK S_OBJ(80) // /obj/effect/abstract/finder/vis_block, marks turfs where vis_blocking is going on
+#define SMOOTH_GROUP_GRAV_FIELD S_OBJ(69)
+
/// Performs the work to set smoothing_groups and canSmoothWith.
/// An inlined function used in both turf/Initialize and atom/Initialize.
#define SETUP_SMOOTHING(...) \
diff --git a/code/datums/proximity_monitor/fields/gravity.dm b/code/datums/proximity_monitor/fields/gravity.dm
index b7e22840041..745072d69e1 100644
--- a/code/datums/proximity_monitor/fields/gravity.dm
+++ b/code/datums/proximity_monitor/fields/gravity.dm
@@ -63,3 +63,63 @@
/datum/proximity_monitor/advanced/gravity/warns_on_entrance/proc/clear_recent_warning(mob_ref_key)
LAZYREMOVE(recently_warned, mob_ref_key)
+
+/obj/gravity_fluff_field
+ icon = 'icons/obj/smooth_structures/grav_field.dmi'
+ icon_state = "grav_field-0"
+ base_icon_state = "grav_field"
+ obj_flags = NONE
+ anchored = TRUE
+ move_resist = INFINITY
+ resistance_flags = INDESTRUCTIBLE | LAVA_PROOF | FIRE_PROOF | UNACIDABLE | ACID_PROOF
+ mouse_opacity = MOUSE_OPACITY_TRANSPARENT
+ pass_flags_self = LETPASSCLICKS
+ smoothing_flags = SMOOTH_BITMASK
+ smoothing_groups = SMOOTH_GROUP_GRAV_FIELD
+ canSmoothWith = SMOOTH_GROUP_GRAV_FIELD
+ alpha = 200
+ /// our emissive appearance
+ var/mutable_appearance/emissive
+
+/obj/gravity_fluff_field/Initialize(mapload, strength)
+ . = ..()
+ if(isnull(strength))
+ return INITIALIZE_HINT_QDEL
+ QUEUE_SMOOTH(src)
+ QUEUE_SMOOTH_NEIGHBORS(src)
+ switch(strength)
+ if(2 to INFINITY)
+ particles = new /particles/grav_field_down/strong()
+ if(1 to 2)
+ particles = new /particles/grav_field_down()
+ if(0 to 1)
+ particles = new /particles/grav_field_float()
+ if(-INFINITY to -1)
+ particles = new /particles/grav_field_up()
+ color = particles.color
+ RegisterSignal(src, COMSIG_ATOM_SMOOTHED_ICON, PROC_REF(smoothed))
+
+/obj/gravity_fluff_field/Destroy(force)
+ . = ..()
+ QDEL_NULL(particles)
+ emissive = null
+
+/obj/gravity_fluff_field/proc/smoothed(datum/source)
+ SIGNAL_HANDLER
+ cut_overlay(emissive)
+ // because it uses a different name
+ emissive = emissive_appearance('icons/obj/smooth_structures/grav_field_emissive.dmi', "grav_field_emissive-[splittext(icon_state, "-")[2]]", src)
+ add_overlay(emissive)
+
+// Subtype which adds a subtle overlay to all turfs
+/datum/proximity_monitor/advanced/gravity/subtle_effect
+
+/datum/proximity_monitor/advanced/gravity/subtle_effect/setup_field_turf(turf/target)
+ . = ..()
+ if(!isopenturf(target))
+ return
+ new /obj/gravity_fluff_field(target, gravity_value)
+
+/datum/proximity_monitor/advanced/gravity/subtle_effect/cleanup_field_turf(turf/target)
+ . = ..()
+ qdel(locate(/obj/gravity_fluff_field) in target)
diff --git a/code/game/machinery/portagrav.dm b/code/game/machinery/portagrav.dm
new file mode 100644
index 00000000000..c970fa5f8f1
--- /dev/null
+++ b/code/game/machinery/portagrav.dm
@@ -0,0 +1,268 @@
+/obj/machinery/power/portagrav
+ anchored = FALSE
+ density = TRUE
+ interaction_flags_machine = INTERACT_MACHINE_ALLOW_SILICON
+ icon = 'icons/obj/machines/gravity_generator.dmi'
+ icon_state = "portagrav"
+ base_icon_state = "portagrav"
+ name = "Portable Gravity Unit"
+ desc = "Generates gravity around itself. Powered by wire or cell. Must be anchored before use."
+ max_integrity = 250
+ circuit = /obj/item/circuitboard/machine/portagrav
+ armor_type = /datum/armor/portable_gravity
+ interaction_flags_click = ALLOW_SILICON_REACH
+ //We don't use area power
+ use_power = NO_POWER_USE
+ ///The cell we spawn with
+ var/obj/item/stock_parts/power_store/cell/cell = /obj/item/stock_parts/power_store/cell/high
+ ///Is the machine on?
+ var/on = FALSE
+ /// do we use power from wire instead
+ var/wire_mode = FALSE
+ /// our gravity field
+ var/datum/proximity_monitor/advanced/gravity/subtle_effect/gravity_field
+ /// strength of our gravity
+ var/grav_strength = STANDARD_GRAVITY
+ /// gravity range
+ var/range = 4
+ /// max gravity range
+ var/max_range = 6
+ /// draw per range
+ var/draw_per_range = BASE_MACHINE_ACTIVE_CONSUMPTION
+
+/datum/armor/portable_gravity
+ fire = 100
+ melee = 10
+ bomb = 40
+
+/obj/machinery/power/portagrav/Initialize(mapload)
+ . = ..()
+ if(ispath(cell))
+ cell = new cell(src)
+ if(anchored && wire_mode)
+ connect_to_network()
+
+ AddElement( \
+ /datum/element/contextual_screentip_bare_hands, \
+ rmb_text = "Toggle power", \
+ )
+
+ var/static/list/tool_behaviors = list(
+ TOOL_WRENCH = list(
+ SCREENTIP_CONTEXT_LMB = "Anchor",
+ ),
+ )
+ AddElement(/datum/element/contextual_screentip_tools, tool_behaviors)
+
+/obj/machinery/power/portagrav/Destroy()
+ . = ..()
+ cell = null
+
+/obj/machinery/power/portagrav/update_overlays()
+ . = ..()
+ if(anchored)
+ . += "portagrav_anchors"
+ if(on)
+ . += "portagrav_o"
+ . += "activated"
+
+/obj/machinery/power/portagrav/examine(mob/user)
+ . = ..()
+ . += "It is [on ? "on" : "off"]."
+ . += "The charge meter reads: [!isnull(cell) ? "[round(cell.percent(), 1)]%" : "NO CELL"]."
+ . += "It is[anchored ? "" : " not"] anchored."
+ if(in_range(user, src) || isobserver(user))
+ . += span_notice("Right-click to toggle [on ? "off" : "on"].")
+
+/obj/machinery/power/portagrav/RefreshParts()
+ . = ..()
+ var/power_usage = initial(draw_per_range)
+ for(var/datum/stock_part/micro_laser/laser in component_parts)
+ power_usage -= BASE_MACHINE_ACTIVE_CONSUMPTION / 10 * (laser.tier - 1)
+ draw_per_range = power_usage
+ var/new_range = 4
+ for(var/datum/stock_part/capacitor/capacitor in component_parts)
+ new_range += capacitor.tier
+ max_range = new_range
+ update_field()
+
+/obj/machinery/power/portagrav/screwdriver_act(mob/living/user, obj/item/tool)
+ . = NONE
+ if(default_deconstruction_screwdriver(user, "[base_icon_state]_o", base_icon_state, tool))
+ return ITEM_INTERACT_SUCCESS
+
+/obj/machinery/power/portagrav/crowbar_act(mob/living/user, obj/item/tool)
+ . = NONE
+ if(default_deconstruction_crowbar(tool))
+ return ITEM_INTERACT_SUCCESS
+
+/obj/machinery/power/portagrav/item_interaction(mob/living/user, obj/item/tool, list/modifiers)
+ . = NONE
+ if(!istype(tool, /obj/item/stock_parts/power_store/cell))
+ return
+ if(!panel_open)
+ balloon_alert(user, "must open panel!")
+ return ITEM_INTERACT_BLOCKING
+ if(cell)
+ balloon_alert(user, "already has a cell!")
+ return ITEM_INTERACT_BLOCKING
+ if(!user.transferItemToLoc(tool, src))
+ return ITEM_INTERACT_FAILURE
+ cell = tool
+ return ITEM_INTERACT_SUCCESS
+
+/obj/machinery/power/portagrav/should_have_node()
+ return anchored
+
+/obj/machinery/power/portagrav/connect_to_network()
+ if(!anchored)
+ return FALSE
+ . = ..()
+
+/obj/machinery/power/portagrav/wrench_act(mob/living/user, obj/item/tool)
+ . = ..()
+ if(on)
+ balloon_alert(user, "turn off first!")
+ return
+ default_unfasten_wrench(user, tool)
+ if(anchored && wire_mode)
+ connect_to_network()
+ else
+ disconnect_from_network()
+ update_appearance()
+ return ITEM_INTERACT_SUCCESS
+
+/obj/machinery/power/portagrav/get_cell()
+ return cell
+
+/obj/machinery/power/portagrav/attack_hand(mob/living/carbon/user, list/modifiers)
+ . = ..()
+ if(!panel_open || isnull(cell) || !istype(user) || user.combat_mode)
+ return
+ if(user.put_in_hands(cell))
+ cell = null
+
+/obj/machinery/power/portagrav/attack_hand_secondary(mob/user, list/modifiers)
+ if(!can_interact(user))
+ return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
+ toggle_on(user)
+ return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
+
+/obj/machinery/power/portagrav/emag_act(mob/user, obj/item/card/emag/emag_card)
+ if(obj_flags & EMAGGED)
+ return FALSE
+ obj_flags |= EMAGGED
+ visible_message(span_warning("Sparks fly out of [src]!"))
+ if(user)
+ balloon_alert(user, "unsafe gravity unlocked")
+ user.log_message("emagged [src].", LOG_ATTACK)
+ playsound(src, SFX_SPARKS, 50, TRUE, SHORT_RANGE_SOUND_EXTRARANGE)
+ return TRUE
+
+/obj/machinery/power/portagrav/proc/toggle_on(mob/user)
+ if(on)
+ turn_off(user)
+ else
+ turn_on(user)
+
+/obj/machinery/power/portagrav/proc/turn_on(mob/user)
+ if(!anchored)
+ if(!isnull(user))
+ balloon_alert(user, "not anchored!")
+ return FALSE
+ if((!wire_mode && cell?.charge < draw_per_range * range) || (wire_mode && surplus() < draw_per_range * range))
+ if(!isnull(user))
+ balloon_alert(user, "not enough power!")
+ return FALSE
+ if(!isnull(user))
+ balloon_alert(user, "turned on")
+ on = TRUE
+ START_PROCESSING(SSmachines, src)
+ gravity_field = new(src, range = src.range, gravity = grav_strength)
+ update_appearance()
+
+/obj/machinery/power/portagrav/proc/turn_off(mob/user)
+ on = FALSE
+ if(!isnull(user))
+ balloon_alert(user, "turned off")
+ STOP_PROCESSING(SSmachines, src)
+ QDEL_NULL(gravity_field)
+ update_appearance()
+
+/obj/machinery/power/portagrav/process(seconds_per_tick)
+ if(!on || !anchored)
+ return PROCESS_KILL
+ if(wire_mode)
+ if(powernet && surplus() >= draw_per_range * range)
+ add_load(draw_per_range * range)
+ else
+ turn_off()
+ else
+ if(!cell?.use(draw_per_range * range))
+ turn_off()
+
+/obj/machinery/power/portagrav/proc/update_field()
+ if(isnull(gravity_field))
+ return
+ gravity_field.set_range(range)
+ gravity_field.gravity_value = grav_strength
+ gravity_field.recalculate_field(full_recalc = TRUE)
+
+/obj/machinery/power/portagrav/ui_interact(mob/user, datum/tgui/ui)
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "Portagrav", name)
+ ui.open()
+
+/obj/machinery/power/portagrav/ui_data(mob/user)
+ . = list()
+ if(!isnull(cell))
+ .["percentage"] = (cell.charge / cell.maxcharge) * 100
+ .["gravity"] = grav_strength
+ .["range"] = range
+ .["maxrange"] = max_range
+ .["on"] = on
+ .["wiremode"] = wire_mode
+ .["draw"] = display_power(draw_per_range * range)
+
+/obj/machinery/power/portagrav/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ if(.)
+ return
+ playsound(src, 'sound/machines/terminal_button07.ogg', 45, TRUE)
+ switch(action)
+ if("adjust_grav")
+ var/adjustment = text2num(params["adjustment"])
+ if(isnull(adjustment))
+ return
+ var/bonus = (obj_flags & EMAGGED) ? 2 : 0
+ // REPLACE 0 with NEGATIVE_GRAVITY ONCE NEGATIVE GRAVITY IS SOMETHING ACTUALLY FUNCTIONAL
+ var/result = clamp(grav_strength + adjustment, 0, GRAVITY_DAMAGE_THRESHOLD - 1 + bonus)
+ if(result == grav_strength)
+ return
+ grav_strength = result
+ update_field()
+ return TRUE
+ if("toggle_power")
+ toggle_on(usr)
+ return TRUE
+ if("toggle_wire")
+ wire_mode = !wire_mode
+ if(wire_mode && anchored)
+ connect_to_network()
+ else
+ disconnect_from_network()
+ return TRUE
+ if("adjust_range")
+ var/adjustment = text2num(params["adjustment"])
+ if(isnull(adjustment))
+ return
+ var/result = clamp(range + adjustment, 0, max_range)
+ if(result == range)
+ return
+ range = result
+ update_field()
+ return TRUE
+
+/obj/machinery/power/portagrav/anchored
+ anchored = TRUE
diff --git a/code/game/objects/effects/particles/gravity.dm b/code/game/objects/effects/particles/gravity.dm
new file mode 100644
index 00000000000..0d74896e20a
--- /dev/null
+++ b/code/game/objects/effects/particles/gravity.dm
@@ -0,0 +1,44 @@
+/particles/grav_field_down
+ icon = 'icons/effects/particles/generic.dmi'
+ icon_state = "cross"
+ width = 100
+ height = 100
+ count = 5
+ spawning = 1
+ lifespan = 0.6 SECONDS
+ fade = 0.5 SECONDS
+ fadein = 0.2 SECONDS
+ position = generator(GEN_CIRCLE, 0, 16, UNIFORM_RAND)
+ gravity = list(0, -0.75)
+ color = "#FF0000"
+
+/particles/grav_field_down/strong
+ gravity = list(0, -1.75)
+
+/particles/grav_field_up
+ icon = 'icons/effects/particles/generic.dmi'
+ icon_state = "cross"
+ width = 100
+ height = 100
+ count = 5
+ spawning = 1
+ lifespan = 0.6 SECONDS
+ fade = 0.5 SECONDS
+ fadein = 0.2 SECONDS
+ position = generator(GEN_CIRCLE, 0, 16, UNIFORM_RAND)
+ gravity = list(0, 0.75)
+ color = "#0077ff"
+
+/particles/grav_field_float
+ icon = 'icons/effects/particles/generic.dmi'
+ icon_state = "cross"
+ width = 100
+ height = 100
+ count = 5
+ spawning = 1
+ lifespan = 0.6 SECONDS
+ fade = 0.5 SECONDS
+ fadein = 0.2 SECONDS
+ position = generator(GEN_CIRCLE, 0, 16, UNIFORM_RAND)
+ velocity = generator(GEN_VECTOR, list(2,0), list(-2,0), UNIFORM_RAND)
+ color = "#FFFF00"
diff --git a/code/game/objects/items/circuitboards/machines/machine_circuitboards.dm b/code/game/objects/items/circuitboards/machines/machine_circuitboards.dm
index 4e7e5f3dd2c..5e41b35d9bb 100644
--- a/code/game/objects/items/circuitboards/machines/machine_circuitboards.dm
+++ b/code/game/objects/items/circuitboards/machines/machine_circuitboards.dm
@@ -1694,3 +1694,12 @@
req_components = list(
/obj/item/pipe/trinary/flippable/filter = 1,
)
+
+/obj/item/circuitboard/machine/portagrav
+ name = "Portable Gravity Unit"
+ greyscale_colors = CIRCUIT_COLOR_ENGINEERING
+ build_path = /obj/machinery/power/portagrav
+ req_components = list(
+ /datum/stock_part/capacitor = 2,
+ /datum/stock_part/micro_laser = 2,
+ /obj/item/stack/sheet/glass = 1)
diff --git a/code/modules/cargo/packs/engineering.dm b/code/modules/cargo/packs/engineering.dm
index 771cba47df6..907ae1598c6 100644
--- a/code/modules/cargo/packs/engineering.dm
+++ b/code/modules/cargo/packs/engineering.dm
@@ -328,3 +328,12 @@
)
crate_name = "radioactive nebula shielding (IMPORTANT)"
crate_type = /obj/structure/closet/crate/engineering
+
+/datum/supply_pack/engineering/portagrav
+ name = "Portable Gravity Unit Crate"
+ desc = "Contains a portable gravity unit, to make the clown float into the ceiling."
+ cost = CARGO_CRATE_VALUE * 4
+ access_view = ACCESS_ENGINEERING
+ contains = list(/obj/machinery/power/portagrav = 1)
+ crate_name = "portable gravity unit crate"
+ crate_type = /obj/structure/closet/crate/engineering
diff --git a/icons/obj/machines/gravity_generator.dmi b/icons/obj/machines/gravity_generator.dmi
index 69395034dc8..18ad0ca0c20 100644
Binary files a/icons/obj/machines/gravity_generator.dmi and b/icons/obj/machines/gravity_generator.dmi differ
diff --git a/icons/obj/smooth_structures/grav_field.dmi b/icons/obj/smooth_structures/grav_field.dmi
new file mode 100644
index 00000000000..4f51707938e
Binary files /dev/null and b/icons/obj/smooth_structures/grav_field.dmi differ
diff --git a/icons/obj/smooth_structures/grav_field.png b/icons/obj/smooth_structures/grav_field.png
new file mode 100644
index 00000000000..5177e577240
Binary files /dev/null and b/icons/obj/smooth_structures/grav_field.png differ
diff --git a/icons/obj/smooth_structures/grav_field.png.toml b/icons/obj/smooth_structures/grav_field.png.toml
new file mode 100644
index 00000000000..49f4000c213
--- /dev/null
+++ b/icons/obj/smooth_structures/grav_field.png.toml
@@ -0,0 +1,5 @@
+output_name = "grav_field"
+template = "bitmask/diagonal_32x32.toml"
+
+[animation]
+delays = [1, 1, 1]
diff --git a/icons/obj/smooth_structures/grav_field_emissive.dmi b/icons/obj/smooth_structures/grav_field_emissive.dmi
new file mode 100644
index 00000000000..a2db63f082a
Binary files /dev/null and b/icons/obj/smooth_structures/grav_field_emissive.dmi differ
diff --git a/icons/obj/smooth_structures/grav_field_emissive.png b/icons/obj/smooth_structures/grav_field_emissive.png
new file mode 100644
index 00000000000..d3746ac5b63
Binary files /dev/null and b/icons/obj/smooth_structures/grav_field_emissive.png differ
diff --git a/icons/obj/smooth_structures/grav_field_emissive.png.toml b/icons/obj/smooth_structures/grav_field_emissive.png.toml
new file mode 100644
index 00000000000..ba6a7f73aaf
--- /dev/null
+++ b/icons/obj/smooth_structures/grav_field_emissive.png.toml
@@ -0,0 +1,5 @@
+output_name = "grav_field_emissive"
+template = "bitmask/diagonal_32x32.toml"
+
+[animation]
+delays = [1, 1, 1]
diff --git a/tgstation.dme b/tgstation.dme
index ab54683d01a..8d65b76d6d3 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -2073,6 +2073,7 @@
#include "code\game\machinery\nebula_shielding.dm"
#include "code\game\machinery\PDApainter.dm"
#include "code\game\machinery\photobooth.dm"
+#include "code\game\machinery\portagrav.dm"
#include "code\game\machinery\prisongate.dm"
#include "code\game\machinery\prisonlabor.dm"
#include "code\game\machinery\quantum_pad.dm"
@@ -2275,6 +2276,7 @@
#include "code\game\objects\effects\landmarks\atmospherics_sanity_landmarks.dm"
#include "code\game\objects\effects\particles\acid.dm"
#include "code\game\objects\effects\particles\fire.dm"
+#include "code\game\objects\effects\particles\gravity.dm"
#include "code\game\objects\effects\particles\misc.dm"
#include "code\game\objects\effects\particles\note_particles.dm"
#include "code\game\objects\effects\particles\slime.dm"
diff --git a/tgui/packages/tgui/interfaces/Portagrav.tsx b/tgui/packages/tgui/interfaces/Portagrav.tsx
new file mode 100644
index 00000000000..d84c5b63842
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Portagrav.tsx
@@ -0,0 +1,134 @@
+import { BooleanLike } from 'common/react';
+
+import { useBackend } from '../backend';
+import { Box, Button, Icon, Section, Stack } from '../components';
+import { Window } from '../layouts';
+
+type Data = {
+ percentage: number;
+ on: BooleanLike;
+ range: number;
+ maxrange: number;
+ gravity: number;
+ wiremode: BooleanLike;
+ draw: number;
+};
+
+export const Portagrav = (props) => {
+ const { act, data } = useBackend();
+ const { percentage, on, range, gravity, wiremode, maxrange, draw } = data;
+
+ return (
+
+
+
+ {!wiremode && percentage !== undefined
+ ? percentage + '%'
+ : wiremode
+ ? 'WIRE POWERED'
+ : 'NO CELL'}
+ {' - ' + gravity + 'G'}
+
+
+ {on ? draw : 'OFF'} / Rng: {range}/{maxrange}
+
+
+
+
+
+
+ Power
+
+
+
+
+
+
+
+
+
+
+
+
+ Gravity Control
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Range Control
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};