Version: 0.33 Status: Active Development Source: Reverse-engineered from decompiled Desktop Goose v0.31 (arkangel-dev/desktop-goose-source) + original PyGoose implementation
PyGoose is a cross-platform, open-source reimplementation of samperson's Desktop Goose. It places a procedurally-animated goose on top of all windows on the user's desktop. The goose wanders autonomously, steals the cursor, tracks mud, and drags fake windows onto the screen. It supports user-customizable memes, notepad messages, and a plugin-based modding API.
The original is closed-source, Windows-only, and unmaintained. This project recreates full feature parity and adds cross-platform support and a cleaner mod API.
| Platform | Status | Notes |
|---|---|---|
| Windows 10/11 | Required | Primary target. Click-through overlay via Qt |
| macOS 12+ | Required | Requires Accessibility permission prompt on first launch |
| Linux (X11) | Required | Wayland unsupported by design; show error if Wayland detected with no XWayland |
Linux Wayland note: Display an error dialog on startup if WAYLAND_DISPLAY is set and DISPLAY is not. Do not silently fail.
| Component | Choice | Rationale |
|---|---|---|
| Language | Python 3.11+ | Cross-platform, readable, easy modding |
| GUI / overlay | PyQt6 | Native transparent always-on-top windows, Qt painter for drawing |
| Cursor control | pyautogui + platform ctypes |
Cross-platform cursor position/move |
| Sound | pygame.mixer |
Cross-platform MP3/WAV, simple API |
| Config | INI via configparser |
Match original format, user-editable |
| Packaging | PyInstaller | Single-folder distribution per platform |
| Mod API | Python importlib plugins |
Drop-in .py files, cleaner than DLL injection |
pygoose/
├── main.py # Entry point
├── config.ini # User configuration file
├── goose/
│ ├── __init__.py
│ ├── game.py # Main game loop (Init, Update, Render)
│ ├── goose.py # TheGoose: state machine, physics, rendering
│ ├── renderer.py # Pure drawing functions (update_rig, render_goose, etc.)
│ ├── config.py # GooseConfig: load/save config.ini
│ ├── sound.py # Sound: honk, chomp, pat, mud squish
│ ├── overlay.py # Transparent always-on-top Qt window
│ ├── cursor.py # Platform cursor clip/release/query helpers
│ ├── mod_loader.py # Plugin discovery and loading
│ └── windows/
│ ├── __init__.py
│ ├── notepad_window.py # SimpleTextForm equivalent
│ ├── meme_window.py # SimpleImageForm equivalent
│ └── movable_window.py # Base movable/resizable window
├── engine/
│ ├── __init__.py
│ ├── vector2.py # Vector2 math class
│ ├── math_utils.py # lerp, clamp, random_range
│ ├── easings.py # All easing functions
│ ├── deck.py # Shuffle-deck random selector
│ ├── rig.py # Rig dataclass: all joint positions + animation state
│ └── time_keeper.py # Frame timer, delta time, elapsed time
├── assets/
│ ├── fonts/ # .ttf/.otf handwriting fonts for notepad
│ ├── sounds/
│ │ ├── Honk1.mp3
│ │ ├── Honk2.mp3
│ │ ├── Honk3.mp3
│ │ ├── Honk4.mp3
│ │ ├── BITE.mp3
│ │ ├── MudSquish.mp3
│ │ ├── Music.mp3 # Optional background music
│ │ ├── Pat1.wav
│ │ ├── Pat2.wav
│ │ └── Pat3.wav
│ ├── images/
│ │ └── memes/ # User drops meme images/GIFs here
│ └── text/
│ └── notepad_messages/ # User drops .txt files here
└── mods/ # User mod folders go here
└── example_mod/
├── mod.py
└── README.md
Exact port of the original SamEngine.Vector2 struct. All operations must match precisely since the rig math depends on them.
class Vector2:
x: float
y: float
zero = Vector2(0.0, 0.0)
# Operators: +, -, unary -, * (vec*vec), * (vec*float), / (vec/float)
# Static methods:
@staticmethod
def get_from_angle_degrees(angle: float) -> Vector2:
# angle * 0.0174532924 (deg2rad constant)
return Vector2(cos(angle * 0.0174532924), sin(angle * 0.0174532924))
@staticmethod
def distance(a, b) -> float: ...
@staticmethod
def lerp(a, b, p) -> Vector2: ...
@staticmethod
def dot(a, b) -> float: ...
@staticmethod
def normalize(a) -> Vector2:
# Guard: if x==0 and y==0, return Vector2.zero
@staticmethod
def magnitude(a) -> float: ...DEG2RAD = 0.0174532924
RAD2DEG = 57.2957764
def random_range(min: float, max: float) -> float:
return min + random.random() * (max - min)
def lerp(a: float, b: float, p: float) -> float:
return a * (1.0 - p) + b * p
def clamp(a: float, min_val: float, max_val: float) -> float:
return min(max(a, min_val), max_val)Single shared random.Random() instance throughout the app.
Implement all of the following exactly as in the original Easings.cs. Only CubicEaseInOut and ExponentialEaseOut are used by the goose, but implement the full set for mod authors.
Used by goose code:
cubic_ease_in_out(p)— foot movement animationexponential_ease_out(p)— ESC quit progress bar
Full list to implement:
linear, quadratic_ease_in/out/in_out, cubic_ease_in/out/in_out,
quartic_ease_in/out/in_out, quintic_ease_in/out/in_out,
sine_ease_in/out/in_out, circular_ease_in/out/in_out,
exponential_ease_in/out/in_out, elastic_ease_in/out/in_out,
back_ease_in/out/in_out, bounce_ease_in/out/in_out
Fisher-Yates shuffle-on-exhaust deck. Exact behavior match is required because the task weighting depends on it.
class Deck:
def __init__(self, length: int):
self.indices = list(range(length))
self._i = 0
self.reshuffle()
def reshuffle(self):
# Fisher-Yates: for i in range(length): swap indices[i] with indices[randint(0, i)]
# NOTE: original uses random_range(0, i) which can return i itself — preserve this
def next(self) -> int:
result = self.indices[self._i]
self._i += 1
if self._i >= len(self.indices):
self.reshuffle()
self._i = 0
return resultTARGET_FRAMERATE = 120 # Hz
DELTA_TIME = 1.0 / 120.0 # 0.008333334 seconds — FIXED, not measured
# time: float — seconds since app start, updated once per frame via time.perf_counter()
# delta_time is ALWAYS 0.008333334 regardless of actual frame timing
# This matches original exactly — the original uses a fixed deltaDelta time is always the fixed constant, not the measured frame time. This is how the original works and affects all physics values. The simulation runs at 120 Hz (two fixed steps per 16 ms timer wake), while rendering runs at 60 Hz with dirty-rect partial repaints — see §7.4 for the actual loop. TimeKeeper.sleep_remainder() is retained for reference but unused; pacing comes from the QTimer interval.
[Goose]
Version=1
EnableMods=False
SilenceSounds=False
Task_CanAttackMouse=True
AttackRandomly=False
UseCustomColors=False
GooseColorBody=#ffffff
GooseColorUnderbody=#d3d3d3
GooseColorBeak=#ffa500
MinWanderingTimeSeconds=20
MaxWanderingTimeSeconds=40
FirstWanderTimeSeconds=20
NotepadFontSize=25Version is a schema version integer. If the loaded version doesn't match the current app version, delete and regenerate with defaults. Show a messagebox warning the user.
@dataclass
class GooseConfig:
version: int = 1
enable_mods: bool = False
silence_sounds: bool = False
task_can_attack_mouse: bool = True
attack_randomly: bool = False
use_custom_colors: bool = False
goose_color_body: str = "#ffffff"
goose_color_underbody: str = "#d3d3d3"
goose_color_beak: str = "#ffa500"
min_wandering_time_seconds: float = 20.0
max_wandering_time_seconds: float = 40.0
first_wander_time_seconds: float = 20.0
notepad_font_size: int = 25If config.ini does not exist, create it silently with defaults (no messagebox). If config fails to parse, show messagebox, delete corrupt file, recreate with defaults.
The overlay is a single frameless, transparent, always-on-top, click-through window covering the entire primary monitor.
flags = (
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool | # hides from taskbar
Qt.WindowType.NoDropShadowWindowHint
)
setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) # click-throughWindows: Additionally call SetWindowLong with WS_EX_LAYERED | WS_EX_TRANSPARENT via ctypes after the window is shown. This is required — Qt's WA_TransparentForMouseEvents alone is insufficient on Windows for all click-through scenarios.
import ctypes
GWL_EXSTYLE = -20
WS_EX_LAYERED = 0x00080000
WS_EX_TRANSPARENT = 0x00000020
hwnd = int(window.winId())
style = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, style | WS_EX_LAYERED | WS_EX_TRANSPARENT)macOS: WA_TransparentForMouseEvents alone is insufficient. After show(), call setIgnoresMouseEvents_(True) on each NSWindow via PyObjC (AppKit.NSApp.windows()). This is required for reliable click-through on macOS. Accessibility permission is also required for cursor manipulation — detect via Quartz.AXIsProcessTrusted() and prompt on startup if not granted.
Linux/X11: WA_TransparentForMouseEvents is sufficient. No extra steps.
Cover QScreen.primaryScreen().geometry() exactly. Do not use availableGeometry() (that excludes taskbar). The goose should be able to walk behind/under the taskbar visually — the taskbar will cover it naturally since it's a separate OS window.
The loop is driven by a single QTimer and is decoupled into a 120 Hz simulation and a 60 Hz render to cut CPU without changing motion or appearance:
- QTimer with interval=16 (~60 wakeups/sec)
- Each wake (_tick in overlay.py):
- on_tick() called TWICE (two fixed 1/120 s physics steps → 120 Hz sim preserved)
- _update_quit() called twice (ESC-hold alpha advances at sim rate)
- schedule a partial repaint over the dirty rect (see below), not the whole screen
- paintEvent(): game.render(painter) — only the invalidated region is repainted
Physics still advances with the fixed DELTA_TIME = 1/120 constant (two steps per wake), so all motion values are identical to a true 120 Hz loop. Only the render cadence dropped to 60 Hz, which is imperceptible for this content.
Dirty-rect repainting: Goose.dirty_rect() returns the bounding box of the goose plus any footmarks currently mid-shrink. overlay._tick() unions the current rect with the previous frame's rect (to erase the trail) and calls self.update(region) instead of a full-screen self.update(). When the ESC quit bar is visible, its rect is unioned in as well. The box extents (DIRTY_LEFT/RIGHT/UP/DOWN in goose.py) are sized to contain every drawn pixel across all states; measured worst-case extents are L≈50 R≈51 U≈96 D≈37 px from the goose position (the up-extent is driven by sleep bubbles). Note: shrinking the box below those generous values yields no measurable CPU saving, because Qt re-blits the whole translucent layered window per update regardless of the invalidated region — so the lever is update frequency, not region area.
Identical-frame skipping (the main idle-CPU win): Goose.dirty_rect() returns None when nothing that affects drawn pixels has changed since the last painted frame, and overlay._tick() then skips the repaint entirely (the layered window already holds identical pixels). The decision compares an exact, un-quantized signature of every render input — position, direction, both feet, the three rig lerps, is_sleeping/show_sleep_bubbles/peek_eye/show_exclamation, sleep_phase (only while bubbles are shown), and whether any footmark is mid-shrink. Because the comparison is bitwise, a skipped frame is provably identical, so this can never change what the user sees. During static behaviours (sit, fake-sleep, stand-still) this drops the goose from ~10% of one core to ~2%. The ESC quit bar animates independently, so the overlay still repaints its region while _quit_alpha is non-zero even when the goose is skipping.
Cached paint objects: renderer.py builds each QPen/QBrush once (keyed by colour+width) and hoists Qt enum values (NoPen, RoundCap, RoundJoin, …) to module constants, instead of constructing pens and re-looking-up enums on every one of the ~15 draw calls per frame. Line endpoints shared between the outline and fill passes are computed once. These are pure-overhead removals with identical output and cut the Python render cost ~20%.
TimeKeeper.sleep_remainder() exists for a sleep-based loop but is not used — the QTimer interval provides pacing instead, letting the Qt event loop idle between wakes.
Use QPainter with RenderHint.Antialiasing enabled.
The goose is 100% procedurally drawn using lines and ellipses. There are no sprite images. All shapes use round line caps.
@dataclass
class Rig:
# Animation lerp values (0.0=relaxed/standing, 1.0=extended/crouched)
neck_lerp_percent: float = 0.0 # 0.0=relaxed, 1.0=extended/running
sit_lerp_percent: float = 0.0 # 0.0=standing, 1.0=fully crouched/sitting
neck_tuck_lerp_percent: float = 0.0 # 0.0=normal neck, 1.0=head tucked down (crawl)
# Pose flags
is_sleeping: bool = False # True only during SleepStage.SLEEPING
show_sleep_bubbles: bool = False # True only during real (non-fake) sleep
peek_eye: int = 0 # 0=no eyes, 1=left eye only, 2=right eye only, 3=both eyes
show_exclamation: bool = False # True during fake-sleep freak-out phase 2
# Sleep bubble animation phase (accumulates DELTA_TIME while sleeping)
sleep_phase: float = 0.0
# Joint positions (updated each frame by update_rig)
underbody_center: Vector2 = field(default_factory=lambda: Vector2(0.0, 0.0))
body_center: Vector2 = field(default_factory=lambda: Vector2(0.0, 0.0))
neck_center: Vector2 = field(default_factory=lambda: Vector2(0.0, 0.0))
neck_base: Vector2 = field(default_factory=lambda: Vector2(0.0, 0.0))
neck_head_point: Vector2 = field(default_factory=lambda: Vector2(0.0, 0.0))
head1_end_point: Vector2 = field(default_factory=lambda: Vector2(0.0, 0.0))
head2_end_point: Vector2 = field(default_factory=lambda: Vector2(0.0, 0.0))sit_lerp_percent and neck_tuck_lerp_percent from the Rig are factored in:
UP = Vector2(0.0, -1.0)
fwd = Vector2.get_from_angle_degrees(direction)
s = rig.sit_lerp_percent
tuck = rig.neck_tuck_lerp_percent
# Body lowers when sitting/crouching
rig.underbody_center = position + UP * lerp(9.0, 1.0, s)
rig.body_center = position + UP * lerp(14.0, 4.0, s)
# Neck height and forward offset interpolate with both lerps
neck_height = int(lerp(lerp(20.0, 10.0, rig.neck_lerp_percent), 6.0, tuck))
neck_forward = int(lerp(lerp(3.0, 16.0, rig.neck_lerp_percent), 2.0, tuck))
rig.neck_center = position + UP * (14 + neck_height)
rig.neck_base = rig.body_center + fwd * 15.0
rig.neck_head_point = rig.neck_base + fwd * neck_forward + UP * neck_height
rig.head1_end_point = rig.neck_head_point + fwd * 3.0 - UP * 1.0
rig.head2_end_point = rig.head1_end_point + fwd * 5.08.3 render_goose(painter, rig, position, direction, l_foot_pos, r_foot_pos, config) — draw order and exact sizes
Draw order (back to front):
- Shadow — hatched dark gray ellipse under body: center=(pos.x, pos.y), radii=(20, 15). Use
Qt.BrushStyle.Dense4Patternat colorQColor(80, 80, 80, 80). Drawn withfillEllipse. - Feet — two filled orange circles, radius=4, at
l_foot_posandr_foot_pos. Color matchesbeak_color. - Outline layer (LightGray, drawn first so white overwrites):
- Underbody line: LightGray, width=15, from
underbody_center + fwd*7tounderbody_center - fwd*7 - Body line: LightGray, width=24, from
body_center + fwd*11tobody_center - fwd*11 - Neck line: LightGray, width=15, from
neck_basetoneck_head_point - Head1 line: LightGray, width=17, from
neck_head_pointtohead1_end_point - Head2 line: LightGray, width=12, from
head1_end_pointtohead2_end_point
- Underbody line: LightGray, width=15, from
- White fill layer (overwrites outline):
- Body line: White, width=22
- Neck line: White, width=13
- Head1 line: White, width=15
- Head2 line: White, width=10
- Beak — Orange, width=11, from
head2_end_pointtohead2_end_point + fwd*5 - Eyes — controlled by
rig.peek_eyeandrig.is_sleeping:left_eye = neck_head_point + UP*3 - right*b.x*3 + fwd*5whereb = Vector2(1.3, 0.4)right_eye = neck_head_point + UP*3 + right*b.x*3 + fwd*5- Both eyes drawn if
not rig.is_sleeping or rig.peek_eye == 3 - Left eye only if
rig.peek_eye == 1 - Right eye only if
rig.peek_eye == 2 - No eyes if
rig.is_sleepingandrig.peek_eye == 0
- Sleep bubbles — drawn if
rig.show_sleep_bubbles(see §8.4) - Exclamation mark — drawn if
rig.show_exclamation(see §8.5)
All lines use Qt.PenCapStyle.RoundCap and Qt.PenJoinStyle.RoundJoin.
Custom colors: When UseCustomColors=True, replace body color with GooseColorBody, outline color with GooseColorUnderbody, and beak/feet color with GooseColorBeak.
Foot marks are rendered separately via render_foot_marks() before render_goose() is called (see §10.3).
Three animated "z" bubbles float upward from above the head. Each has a staggered phase offset.
base = rig.neck_head_point + UP * 10.0
configs = [
{"offset": 0.0, "x_drift": 6.0, "max_r": 3.5},
{"offset": 1.0, "x_drift": 11.0, "max_r": 5.0},
{"offset": 2.0, "x_drift": 15.0, "max_r": 6.5},
]
cycle = 3.0 # seconds per bubble cycle
rise = 28.0 # px of upward travel
for cfg in configs:
p = ((rig.sleep_phase + cfg["offset"]) % cycle) / cycle # 0→1
# Fade out in second half of cycle
alpha = int(clamp(lerp(220, 0, max(0.0, (p - 0.5) * 2.0)), 0, 255))
r = lerp(1.5, cfg["max_r"], p)
pos = Vector2(base.x + cfg["x_drift"], base.y - p * rise)
color = QColor(255, 255, 255, alpha)
outline = QColor(160, 200, 255, alpha)Yellow anime-style "!" drawn above the head during fake-sleep freak-out phase 2.
base = rig.neck_head_point + UP * 22.0
# Vertical bar: tall thin rounded rect
bar = QRectF(base.x - 3.5, base.y - 14.0, 7.0, 10.0)
painter.drawRoundedRect(bar, 2.0, 2.0) # yellow fill, black outline (width=1.5)
# Dot below
painter.drawEllipse(QPointF(base.x, base.y), 3.5, 3.5)
COLOR_YELLOW = QColor(0xFF, 0xE0, 0x00)# Each frame (in tick()):
target_neck = 1.0 if (override_extend_neck or current_speed >= 200.0) else 0.0
rig.neck_lerp_percent = lerp(rig.neck_lerp_percent, target_neck, 0.075)
# sit_lerp_percent and neck_tuck_lerp_percent are lerped toward _target_sit_lerp
# and _target_neck_tuck at rate 0.06 per frame:
rig.sit_lerp_percent = lerp(rig.sit_lerp_percent, _target_sit_lerp, 0.06)
rig.neck_tuck_lerp_percent = lerp(rig.neck_tuck_lerp_percent, _target_neck_tuck, 0.06)override_extend_neck is set to True during CollectWindow.DraggingWindowBack task and during SNEAK_ATTACK.POUNCING/DRAGGING stages.
class SpeedTier(Enum):
SNEAK = "sneak"
WALK = "walk"
RUN = "run"
CHARGE = "charge"
SPEEDS = {
SpeedTier.SNEAK: {"speed": 28.0, "acceleration": 600.0, "step_time": 0.45},
SpeedTier.WALK: {"speed": 80.0, "acceleration": 1300.0, "step_time": 0.2},
SpeedTier.RUN: {"speed": 200.0, "acceleration": 1300.0, "step_time": 0.2},
SpeedTier.CHARGE: {"speed": 400.0, "acceleration": 2300.0, "step_time": 0.1},
}SNEAK is used during fake-sleep post-freak-out peek behavior, sneak attack approach, and sleep circling. Slow speed (28px/s), wide step timing (0.45s), produces a careful creeping walk.
# 1. Compute target_direction (normalized vector toward target_pos)
to_target = Vector2.normalize(target_pos - position)
# 2. Run AI (updates target_pos, may set override_extend_neck, _freeze_position)
run_ai()
# 3. Freak-out override (see §9.4) — overrides target_pos and speed if active
# 4. Turn toward target (lerp angle, 25% per frame)
current_dir_vec = Vector2.get_from_angle_degrees(direction)
blended = Vector2.lerp(current_dir_vec, to_target, 0.25)
direction = degrees(atan2(blended.y, blended.x))
# 5. If _freeze_position: zero velocity, return early
if _freeze_position:
velocity = Vector2.zero
return
# 6. Cap velocity to current_speed
if Vector2.magnitude(velocity) > current_speed:
velocity = Vector2.normalize(velocity) * current_speed
# 7. Accelerate toward target
velocity += Vector2.normalize(target_pos - position) * current_acceleration * DELTA_TIME
# 8. Integrate position
position += velocity * DELTA_TIMEWhen _freeze_position = True, the physics integration step is skipped (velocity zeroed, position unchanged). The direction lerp still runs so the goose can face its target while standing still. Tasks use this flag to hold the goose in place during e.g. watching, sleeping, or sweeping.
When the goose is caught fake-sleeping, it transitions to a 4-second freak-out before returning. This is implemented as a physics-layer override that runs in _run_physics after the AI dispatcher, so it takes full control regardless of the current task.
# Two bounce points 400px away from the goose (in the direction away from mouse),
# offset ±70px perpendicular:
away = Vector2.normalize(position - mouse_pos)
perp = Vector2(-away.y, away.x)
off_base = position + away * 400.0
_freak_bounce_a = off_base + perp * 70.0
_freak_bounce_b = off_base - perp * 70.0
# Each frame while freak-out is active:
if _freak_out_until > 0 and t < _freak_out_until:
bounce_target = _freak_bounce_a if _freak_bounce_to_a else _freak_bounce_b
target_pos = bounce_target
if distance(position, bounce_target) < 30.0:
_freak_bounce_to_a = not _freak_bounce_to_a # toggle target
_set_speed(SpeedTier.CHARGE)
_freeze_position = False
if t >= _freak_out_next_honk:
sound.honk()
_freak_out_next_honk = t + 0.3 # honk every 0.3 seconds
# When freak-out expires:
elif _freak_out_until > 0:
_freak_out_until = -1.0
_set_task(Task.PEEK_BACK, honk=False)# In tick(), before running AI:
mouse_down = is_left_mouse_down()
if mouse_down and not last_frame_mouse_button_pressed:
cursor_pos = QCursor.pos()
goose_head = Vector2(position.x, position.y + 14.0)
if distance(goose_head, cursor_pos) < 30.0:
if current_task == Task.SLEEP and task_sleep.stage == SleepStage.SLEEPING:
# Clicking sleeping goose wakes it with a honk
sound.honk()
set_task(Task.WANDER)
elif current_task == Task.WATCH_MOUSE and sub_state == WatchSubState.SIT:
r = random()
if r < 0.70: sound.honk()
elif r < 0.90: set_task(Task.WANDER)
else: set_task(Task.NAB_MOUSE)
else:
set_task(Task.NAB_MOUSE)
last_frame_mouse_button_pressed = mouse_downFOOT_MARK_BUFFER_SIZE = 64
@dataclass
class FootMark:
position: Vector2 = Vector2.zero
time: float = 0.0 # timestamp when mark was placed
foot_marks: list[FootMark] = [FootMark() for _ in range(64)]
foot_mark_index: int = 0 # wraps aroundA footmark is placed whenever a foot completes a step AND time.time < track_mud_end_time.
def add_foot_mark(pos: Vector2):
foot_marks[foot_mark_index].time = time.time
foot_marks[foot_mark_index].position = pos
foot_mark_index = (foot_mark_index + 1) % FOOT_MARK_BUFFER_SIZEFOOT_MARK_LIFETIME = 8.5 # seconds before shrink begins
FOOT_MARK_SHRINK_TIME = 1.0 # seconds to shrink from radius 3 to 0
for mark in foot_marks:
if mark.time == 0.0:
continue
shrink_start = mark.time + FOOT_MARK_LIFETIME
p = clamp((time.time - shrink_start) / FOOT_MARK_SHRINK_TIME, 0.0, 1.0)
radius = lerp(3.0, 0.0, p)
if radius > 0:
painter.drawEllipse(center=mark.position, radius=radius, color=SaddleBrown)Feet are solved using a two-foot IK system that alternates steps.
The foot home adapts to the crawl/sit pose via rig.sit_lerp_percent:
FEET_DISTANCE_APART = 6.0
OVERSHOOT_FRACTION = 0.4
WANT_STEP_AT_DISTANCE = 5.0
def get_foot_home(right_foot: bool) -> Vector2:
s = rig.sit_lerp_percent
b = 1.0 if right_foot else 0.0
side = Vector2.get_from_angle_degrees(direction + 90.0) * b
# When crawling: reduce perpendicular spread and push feet downward
perp_dist = lerp(FEET_DISTANCE_APART, 2.0, s)
crawl_drop = lerp(0.0, 8.0, s)
return position + side * perp_dist + Vector2(0.0, crawl_drop)Only one foot moves at a time. Steps alternate: when neither foot is moving, check left foot first.
if l_foot_move_time_start < 0 and r_foot_move_time_start < 0:
# Check if left foot needs to step
if distance(l_foot_pos, get_foot_home(False)) > WANT_STEP_AT_DISTANCE:
l_foot_move_origin = l_foot_pos
l_foot_move_dir = normalize(get_foot_home(False) - l_foot_pos)
l_foot_move_time_start = time.time
# Else check right foot
elif distance(r_foot_pos, get_foot_home(True)) > WANT_STEP_AT_DISTANCE:
r_foot_move_origin = r_foot_pos
r_foot_move_dir = normalize(get_foot_home(True) - r_foot_pos)
r_foot_move_time_start = time.time
elif l_foot_move_time_start > 0:
target = get_foot_home(False) + l_foot_move_dir * OVERSHOOT_FRACTION * 5.0
elapsed = time.time - l_foot_move_time_start
if elapsed <= step_time:
p = elapsed / step_time
l_foot_pos = Vector2.lerp(l_foot_move_origin, target, cubic_ease_in_out(p))
else:
l_foot_pos = target
l_foot_move_time_start = -1.0
sound.play_pat()
if time.time < track_mud_end_time:
add_foot_mark(l_foot_pos)
elif r_foot_move_time_start > 0:
# (same as left)step_time comes from the current speed tier (0.45 for Sneak, 0.2 for Walk/Run, 0.1 for Charge).
class Task(Enum):
WANDER = "wander"
NAB_MOUSE = "nab_mouse"
COLLECT_WINDOW_MEME = "collect_window_meme"
COLLECT_WINDOW_NOTEPAD = "collect_window_notepad"
COLLECT_WINDOW_EXEC = "collect_window_exec" # internal, not picked directly
TRACK_MUD = "track_mud"
WATCH_MOUSE = "watch_mouse"
FOLLOW_MOUSE = "follow_mouse"
SNEAK_ATTACK = "sneak_attack"
SLEEP = "sleep"
PEEK_BACK = "peek_back" # internal, triggered by fake-sleep freak-outPEEK_BACK is never picked from the weighted list — it is only triggered automatically at the end of a fake-sleep freak-out sequence.
TASK_WEIGHTED_LIST = [
Task.TRACK_MUD, # 2/16
Task.TRACK_MUD,
Task.COLLECT_WINDOW_MEME, # 2/16
Task.COLLECT_WINDOW_MEME,
Task.COLLECT_WINDOW_NOTEPAD, # 3/16
Task.COLLECT_WINDOW_NOTEPAD,
Task.COLLECT_WINDOW_NOTEPAD,
Task.NAB_MOUSE, # 3/16
Task.NAB_MOUSE,
Task.NAB_MOUSE,
Task.WATCH_MOUSE, # 2/16
Task.WATCH_MOUSE,
Task.FOLLOW_MOUSE, # 2/16
Task.FOLLOW_MOUSE,
Task.SNEAK_ATTACK, # 1/16
Task.SLEEP, # 1/16
]
# 16 entries total
task_picker_deck = Deck(len(TASK_WEIGHTED_LIST))def choose_next_task():
if DEV_FORCE_TASK:
set_task(Task(DEV_FORCE_TASK))
return
task = TASK_WEIGHTED_LIST[task_picker_deck.next()]
# Skip any unimplemented tasks (fall back to wander)
if task not in implemented_tasks:
task = Task.WANDER
# If AttackRandomly=False, skip NabMouse picks
if not config.attack_randomly and task == Task.NAB_MOUSE:
task = Task.WANDER
set_task(task)Every task transition resets these Rig fields and flags:
override_extend_neck = False
_target_sit_lerp = 0.0
_target_neck_tuck = 0.0
_freeze_position = False
rig.is_sleeping = False
rig.show_sleep_bubbles = False
rig.peek_eye = 0
rig.show_exclamation = False
rig.sleep_phase = 0.0
current_task = task
if honk:
sound.honk()
# Then initialize task-specific state...All behavior logic lives in pygoose/goose/behaviors/. Each behavior is a self-contained module — one file per behavior — containing its State dataclass, optional Stage enum, constants, and two functions: enter(goose) and tick(goose).
goose.py holds two dispatch dicts:
_BEHAVIOR_ENTER: dict[Task, Callable] = { Task.WANDER: wander.enter, ... }
_BEHAVIOR_TICK: dict[Task, Callable] = { Task.WANDER: wander.tick, ... }_set_task calls _BEHAVIOR_ENTER[task](self), and the main tick() calls _BEHAVIOR_TICK[self.current_task](self) every frame. Per-behavior state is stored in goose.task_state (typed object), cast inside the behavior file.
Circular import pattern: behavior files use TYPE_CHECKING guards for type hints and defer all runtime imports from goose.py inside function bodies:
if TYPE_CHECKING:
from pygoose.goose.goose import Goose
def tick(goose: Goose) -> None:
from pygoose.goose.goose import SpeedTier, SPEEDS # deferred runtime import
...Behaviors communicate with core systems through designated override slots — plain attributes on Goose or Rig that the behavior writes and core methods read, without branching on behavior identity. This keeps core methods generic and behavior logic contained.
Rules:
- Behaviors write to override slots; core reads them blindly — no
if waddling:orif self.current_task == X:in core methods - All override slots are reset to their neutral values in
_set_taskso they never bleed into the next behavior - To add a new override slot: add a neutral-default attribute to
Goose.__init__, reset it in_set_task, read it generically in the relevant core method
Existing override slots:
| Slot | Lives on | Neutral | Used by | Read by |
|---|---|---|---|---|
| (none currently assigned) |
This table fills in as behaviors adopt the pattern. Add a row here whenever a new slot is introduced.
Adding a behavior-specific step distance (example):
# goose.py __init__
self.step_distance_override: float = 0.0 # 0 = use default
# goose.py _set_task reset block
self.step_distance_override = 0.0
# goose.py _solve_feet (reads blindly — no behavior check)
threshold = self.step_distance_override or WANT_STEP_AT_DISTANCE
# wander.py (behavior sets it)
goose.step_distance_override = 12.0What NOT to do:
- Do not modify
_solve_feetlogic for a specific behavior - Do not add
if waddling:/ behavior identity checks to any core method - Do not add override slots without resetting them in
_set_task
Props are physical objects the goose carries in, leaves on the desk, and interacts with. The system is designed around a registry pattern — adding a new prop type means writing a render function and one registry entry, nothing else.
PyGoose uses a loose isometric-style 2D view from slightly above:
- Screen up = away from viewer (goose walking toward top is walking into the distance)
- Screen down = toward viewer
- Prop Z = height off the desk floor toward the viewer — a separate axis, not the same as screen-up/away
The shadow resolves the visual ambiguity: screen_y = ground_y - z * perspective_scale. Shadow stays pinned at prop.position (ground). The gap between shadow and object reads as height.
perspective_scale is tuned after rendering is in — start at 1.0.
| State | Description |
|---|---|
CARRIED |
Attached to beak, position driven by rig each frame |
WORN |
Attached to a rig point (e.g. hat on head), moves with it, click-transparent |
FALLING |
Z decreasing under gravity, shadow pinned at ground |
TIPPING |
Pre-fall wobble for heavy props — spring oscillator rocks prop before committing to FALLING. Gives user anticipation. Only props with can_tip=True. |
CLATTERING |
Hit ground, skidding/tumbling with friction before settling |
BOUNCING |
Elastic Z bounce, loses height each hit, eventually settles |
RISING |
Balloon released, Z increasing, drifts off top of screen |
PLACED |
Static at rest on surface |
ACTIVE |
Functional animation running (fan spinning, etc.) |
BROKEN |
Destroyed form, static remains |
@dataclass
class Prop:
prop_type: PropType
position: Vector2 # ground position — where shadow sits
z: float = 0.0 # height above surface (0 = resting on surface)
z_vel: float = 0.0
angle: float = 0.0
velocity: Vector2 = field(default_factory=Vector2)
angular_velocity: float = 0.0
state: PropState = PropState.PLACED
time_in_state: float = 0.0
scale: float = 1.0
prop_data: object = None # type-specific state (fan angle, bounce count, etc.)
surface_z: float = 0.0 # Z of the surface this prop rests on:
# 0.0 = desk floor
# WINDOW_Z_HEIGHT = on a goose window
# prop_below.surface_z + prop_below.z = stacked on another prop
# True world height = surface_z + z
is_owned: bool = False # True = goose retrieves if disturbed/dragged; False = litter, click to sweep@dataclass
class PropDef:
render_fn: Callable
render_broken_fn: Callable | None = None
break_fn: Callable | None = None # fn(position, velocity) → list[PropFragment]
gravity_scale: float = 1.0 # 1.0=fall, -0.6=balloon rises
restitution: float = 0.1
friction: float = 0.6
mass: float = 1.0 # affects collision energy transfer — heavier absorbs more
can_tip: bool = False # whether prop has TIPPING pre-fall state
tip_velocity: float = 999.0 # minimum impulse to enter TIPPING
fall_velocity: float = 999.0 # minimum impulse to skip TIPPING → straight FALLING
can_break: bool = False
break_velocity: float = 999.0 # minimum impulse → instant BROKEN
bounces_off_edges: bool = False
carry_offset_fn: Callable = default_carry_offset
idle_animation: bool = False
collision_radius: float = 10.0 # radius for prop-to-prop collision detection
attachment_point: str | None = None # rig point name for WORN props (e.g. "HEAD")PROP_REGISTRY: dict[PropType, PropDef] — adding a new prop = write render fn + add one entry.
Prop physics lives in pygoose/goose/props/physics.py. goose.py calls tick_props(props, fragments, goose_state, dt) once per frame.
Physics philosophy: PyGoose is a personality engine, not a physics simulation. Thresholds (tip_velocity, fall_velocity, break_velocity) are design levers tuned by feel. Collision response is a single scalar multiply (velocity × mass ratio). TIPPING uses a spring oscillator for organic feel. This keeps overhead minimal and behavior predictable.
Collision detection: radius check between all prop pairs each tick. O(n²) but negligible at max 7 props (15 pairs). Both moving and stationary props checked. Outcome determined by velocity threshold stack.
Render layering:
- Prop at Z=0 (desk) → renders under goose windows
- Prop at Z = WINDOW_Z_HEIGHT → on window surface, renders over window
- Prop at Z > WINDOW_Z_HEIGHT → above window surface, renders over window
- Render order follows
surface_z + z, not a flat "props always on top" rule
Props are either goose's property (is_owned=True) or litter (is_owned=False). Tracked per-instance so ownership can change.
- Owned: goose retrieves if knocked over or dragged. High-priority task injection (same pattern as window interactions). Draggable by user — goose chases and retrieves.
- Litter: click to sweep. Goose doesn't care.
Which props are owned vs litter TBD per type. Ownership can change (e.g. balloon weight becomes litter after string is cut).
CARRY_PROP is a single entry in TASK_WEIGHTED_LIST like any other task. When the deck picks it, carry_prop.enter() makes all further decisions:
- Props already on screen? Pick an interaction: play with it, remove it, or break it — weighted by prop type (breakables favor break, ball favors play, old/excess props favor removal).
- No props on screen? Carry a new one in — pick type from an internal weighted list: common (knife, fork, spoon), medium (balloon, hat), very rare (skateboard).
No dynamic injection into the task picker. _choose_next_task and the deck are untouched. All decisions live inside carry_prop.py. Max 7 props on screen (tunable).
| File | Contents |
|---|---|
pygoose/goose/props/__init__.py |
Empty package marker |
pygoose/goose/props/prop.py |
PropType, PropState, FragmentShape, FragmentState, Prop, PropDef, PropFragment, PROP_REGISTRY |
pygoose/goose/props/prop_renderer.py |
render_prop, render_fragment dispatch + per-type render fns |
pygoose/goose/props/physics.py |
tick_props(props, fragments, goose_state, dt) |
pygoose/goose/behaviors/carry_prop.py |
Carry-in, drop, pickup with internal prop type weighted list |
Modified files: goose.py (props/fragments lists, Task.CARRY_PROP, dispatch, physics tick call), overlay.py (render props and fragments)
For each new prop, in this order:
- Scaffolding — add
PropTypeenum value, stub entry inPROP_REGISTRY, stub render function returning nothing. - DEV flag — set
DEV_ForceSpawnProp = <prop_name>andDEV_HideGoose = Truein config.ini. This spawns the prop at the debug position with the full debug visualization active. - Visual iteration — run the goose and iterate QPainter shapes, colors, line weights until the prop looks right. Do not move on until the visual is locked.
- Derive everything else — carry offset, collision radius, attachment point, break fragment shapes and colors all come directly from the finalized visual. Do not guess these upfront.
- Implement physics states and behavior — add states, transitions, and carry_prop behavior logic.
Why this order: the visual is what everything else derives from. Designing physics before the visual means reworking all interaction points every time the shape changes.
Triggered by DEV_ForceSpawnProp in config.ini. Also set DEV_HideGoose = True and DEV_ForceTask = wander when using this mode. Implemented in prop_renderer.py _render_debug_box.
Layout: two white boxes stacked vertically — main box on top, compass below — no separator between them. Default position: left side of screen, with the stack anchored at (270, screen_h - 728) so the compass bottom clears the taskbar by ~50px. A "⇄ flip screen pos" button inside the top of the main box toggles the entire stack to the right side (screen_w - 270) and back, so the user can expose whichever side of the desktop they need. Click detection is handled in _check_petting using the stored _debug_flip_rect.
In dirty_rect(), when _dev_debug_props is True the full-screen rect is always returned, bypassing the dirty-rect optimization. This ensures the debug boxes repaint every frame regardless of where the invisible goose wanders.
Main box (500×340): up to three prop variants centered vertically, each labeled (v1/v2/v3) with the currently active variant marked by an orange dot. Two reference geese (facing 180° and 45°) in the upper portion for body-size reference. Right side (at cx+195, vertically mid-box): a single prop copy showing only its defined attach points as colored dots — hot pink for carry, full green for head, full blue for back. Bottom-left: two shadow previews labeled "prop w/ shadow (carried)" (Z=30, smaller shadow) and "prop w/ shadow (placed)" (Z=0, full-size shadow) to validate the dithered ellipse shadow at different heights — both call _render_shadow directly so they always match the live system. Bottom-center: two live oscillating shadow previews sharing the same sine-wave Z (period ~3.5s, range 0→30→0): left prop at 90° (vertical orientation), right prop at 0° (horizontal), both centered under the "live shadow (sine wave)" label at cx±35. Both use _render_shadow for the shadow and call _draw_knife_shape with painter.rotate(angle) for the sprite. Bottom-right legend: orange dot "currently shown" (active variant), then three colored dot entries for attach point types.
_DEBUG_ACTIVE_VARIANT in prop_renderer.py controls which variant is rendered in the compass, shadow previews, and attach point view simultaneously. Change it to "v1", "v2", or "v3" and restart to switch all test views at once.
Compass box (500×500): eight reference geese arranged in a circle, each facing outward in their exact compass direction (N/NE/E/SE/S/SW/W/NW). Each goose has the prop placed in carry position using the same rig math that runs in the live behavior — rig.head2_end_point + fwd * 5 for the beak tip, direction + 90 for the carry angle, and the same translate offset. This is a free integration test: if the prop looks right on all 8 geese, it will look right at runtime at any angle without per-direction tuning. Without it, a carry position that looks fine facing left may clip the neck facing up-right.
Each compass goose also renders a prop shadow beneath its body shadow, using _render_shadow with the same shadow dimensions as the live system. Shadow position formula:
shadow_x = pos.x + fwd.x * 31.0
shadow_y = pos.y + fwd.y * 25.0The facing direction (fwd) is used rather than the carry direction, so the shadow always appears directly ahead of the goose — where the beak and knife are. The x/y values differ (31 vs 25) because the body shadow ellipse is wider than it is tall: the same pixel gap looks proportionally larger vertically, so the y offset is dialed down to produce consistent perceived spacing in all 8 views. These values are tuned by eye with E/W as the reference.
Compass draw order:
_render_shadow(prop_shadow) # prop shadow under everything
render_goose_body(...) # goose shadow, feet, outline, white fill
_render_<prop>_in_beak(...) # prop sits above body
render_goose_head(...) # beak and eyes on top of prop
In _render_<prop>_in_beak:
fwd = Vector2.get_from_angle_degrees(direction)
beak_tip = rig.head2_end_point + fwd * 5.0 # actual tip — head2_end_point is the beak BASE
painter.save()
painter.translate(beak_tip.x, beak_tip.y)
painter.rotate(direction + 90) # prop faces goose's right
painter.translate(N, 0) # prop's x=0 sits N px ahead of beak tip along carry axis
painter.scale(1, -1) # mirror Y so cutting edge faces away from body
_draw_prop_shape(painter, ...)
painter.restore()N is the carry offset: the prop's local x=0 (grip junction) is N px ahead of the beak tip. The beak grips at local x = -N. Increase N to slide further toward the tip; decrease to grip further toward the back. The hot pink dot in the debug main box is drawn at x = -N on the horizontally-displayed prop so you can see exactly where the beak makes contact.
Props held in the beak must render above the body but below the beak and eyes. render_goose in renderer.py is split for this purpose:
render_goose_body(painter, rig, position, direction, l_foot, r_foot, config)— shadow, feet, outline layer, white fill layerrender_goose_head(painter, rig, direction, config)— beak, eyes, sleep bubbles, exclamation markrender_goose(...)— wrapper calling both in sequence; existing callers unaffected
The live carry behavior must follow the same order: draw body → draw prop → draw head. Never draw a carried prop after render_goose — it will appear on top of the beak and eyes.
-
PropTypeenum value added toprop.py - Stub render function written and registered in
PROP_REGISTRY - Prop added to compass box and main debug box variants in
prop_renderer.py - Visual iterated to final state using debug system
- Carry attach offset (
N) confirmed via hot pink dot in debug box -
_render_<prop>_in_beakwritten usingrender_goose_body/render_goose_headsplit - Collision radius derived from finalized visual
- Physics states implemented in
props/physics.py -
carry_prop.enter()updated to include new prop type in weighted selection
The correct mental model: the debug box is a prop design tool that any prop lives inside. It is not something the knife owns. The knife was just the first prop to use it and served as the reference implementation to establish the system. Every future prop — fork, spoon, vase, balloon — should slot into the same tool with zero changes to the debug rendering code itself.
The debug box is currently knife-specific: _draw_knife_shape, _render_knife_in_beak, and _DEBUG_KNIFE_VARIANTS are all hardcoded to the knife. This is intentional — the knife was the reference implementation and its visual design is now locked. The refactor is queued but deferred until the second prop is added (see trigger below).
Once the knife is fully locked, refactor the debug system so it drives any prop through the registry instead of knife-specific calls:
_DEBUG_ACTIVE_PROP: PropTypereplaces_DEBUG_ACTIVE_VARIANT: str- Variant definitions (shape params, labels) move into
PropDefor a companiondebug_variantsstructure on each prop - The compass calls
PropDef.carry_render_fn(painter, rig, direction)instead of_render_knife_in_beak - The shadow previews and attach point view call
PropDef.render_fninstead of_draw_knife_shape - The main box variant rows iterate
PropDef.debug_variantsinstead of_DEBUG_KNIFE_VARIANTS
End state: changing _DEBUG_ACTIVE_PROP to any registered prop type repoints all test views — variants, compass, shadows, attach points — with zero changes to the debug rendering code.
Trigger: when adding the second prop. That's the natural moment — two props force the abstraction and provide a concrete second case to design against.
Carry-in sequence:
- Goose walks off screen like collecting a notepad — no special exit animation.
- Knife appears in beak while off-screen (rendered from the moment the goose re-enters the edge), exactly like how the notepad window pre-renders off-screen before drag-in. The goose never "picks it up" on-screen; he already has it.
- Goose wanders back onto the desktop carrying the knife.
Wander-with-knife phase:
- Goose wanders normally (same speed, same pauses) with the knife visible in beak the whole time.
- Hold duration committed upfront on carry-in: random draw, weighted toward shorter end, range roughly 20s–3min.
- At the end of the hold duration (or on interrupt), goose drops or places the knife.
Drop types (chosen randomly, except on interrupt):
- Place — head dips down until beak tip reaches the knife's grab point at ground level; knife transfers cleanly to Z=0. Deliberate and calm.
- Drop — releases from carry height (Z=30); knife falls and bounces per physics. Casual or abrupt.
- Panic/startle always forces a drop (never a place).
Pickup animation:
When the goose later retrieves a placed or dropped knife, his head dips down until the beak tip reaches the knife's ground-level grab point, then lifts back up with the knife in beak. Dip depth is driven by the knife's actual position.y — beak must physically reach it.
Note/meme surface interaction (planned, Z system not yet implemented): If a note or meme window is on screen, the probability of carrying the knife up to that surface increases. The goose can:
- Walk up to the note surface (using future z-climbing behavior), carry the knife onto it, and either place it on the edge immediately or drop it there during a later wander.
- The knife sitting on a note edge is then a candidate for the "nudge off edge" behavior — goose returns, nudges it, knife falls to desktop with a bounce. Design this as a weight modifier on the drop-destination choice, not a separate task.
Trigger: Available in the task deck whenever the knife is on-screen in any state (carried or placed). Two entry paths:
- Goose already carrying knife → transitions directly into threat mode.
- Knife placed/dropped on screen → goose runs to knife, performs pickup animation, then transitions into threat mode.
Approach phase:
- Goose faces cursor and walks slowly toward it, knife out.
- Stops at a close-ish distance (~80–120px, tunable).
Stare-down phase:
- Holds position facing cursor.
- Occasional head jab toward cursor (quick forward-bob of the head, pull back).
- Occasional faint lunge with a honk — short forward dash toward cursor, then retreats to hold distance.
- All posturing — no actual cursor interaction.
Exit A — cursor invades (startle):
- If the cursor comes within a very close distance to the goose (as if trying to steal the knife back), goose startles, drops knife immediately (always a drop, never a place), and runs away in a full freak-out.
Exit B — timeout (goose wins):
- Triumphant honk.
- Walks a short distance away.
- Walks a full victory lap circle, head bobbing continuously up and down the entire time, honking throughout. Knife remains in beak.
- Lap complete → drops knife with no ceremony, mid-stride. Transitions to normal wander.
When the deck picks CARRY_PROP, all decisions are made inside carry_prop.enter(). The deck and _choose_next_task are untouched. The model has three layers.
Layer 1 — Assess world state
Count what is on screen: by prop type, by state (PLACED, FALLING, CARRIED, etc.), and total count vs. the global cap. This snapshot drives everything below.
Layer 2 — Decide: interact, remove, or fetch
- Props with available interactions on screen → weighted toward interaction
- At or above global prop cap → weighted toward removal
- Nothing on screen → fetch new
These are weights, not hard rules — a nearly-full screen still has a chance to fetch, and a single prop still has a chance to be removed if it has no interesting interactions.
Layer 3 — Execute
- Long-running interaction (own stages, own state machine) →
_set_task(Task.X)immediately.carry_prophands off entirely; the new behavior file owns everything from that point. - Short interaction (nudge, walk past, brief animation) → runs inline inside existing
carry_propstages. No new task or behavior file needed. - Fetch new → pick type from internal weighted list (common / medium / rare tiers). Exit offscreen, re-enter carrying the prop. Uses the existing EXITING → WAITING → ENTERING stage flow.
- Remove → walk to the oldest or most numerous prop of a type, retrieve it (PICKUP_WALK → PICKUP_STOP → PICKUP_DIP), then exit offscreen without re-entering.
Per-prop interaction registration
Each prop type declares its supported interactions directly in its PropDef entry in PROP_REGISTRY — a list of (weight, Task) pairs for long-running interactions, plus a can_remove flag governing whether removal is a candidate for that type. carry_prop.enter() iterates all props currently on screen, builds a weighted pool from their registered interactions, and picks. A prop type with no interactions registered is only ever a candidate for removal or fetch — never for a dedicated behavior.
File ownership
props/— what props are: data (prop.py), rendering (prop_renderer.py), physics (physics.py)behaviors/— what the goose does:carry_prop.py(decision layer + carry stages) and one file per long-running prop interaction (e.g.knife_threat.py)
What never changes regardless of how many prop types exist
- Single
CARRY_PROPentry inTASK_WEIGHTED_LIST _choose_next_taskand the deck untouched- All existing
carry_propstages (EXITING, WAITING, ENTERING, PICKUP_WALK, PICKUP_STOP, PICKUP_DIP, WANDERING, PAUSING, PLACING) stay as-is - New prop-specific behaviors are new files in
behaviors/, dispatched via the existing_BEHAVIOR_ENTER/_BEHAVIOR_TICKtables
@dataclass
class WanderState:
wander_start_time: float
wander_duration: float
pause_start_time: float = -1.0
pause_duration: float = 0.0
# Duration constants:
MIN_PAUSE = 1.0 # seconds
MAX_PAUSE = 2.0 # seconds
GOOD_ENOUGH_DIST = 20.0
def get_random_wander_duration() -> float:
if DEV_SHORT_WANDER:
return 3.0
return random_range(config.min_wandering_time_seconds, config.max_wandering_time_seconds)def run_wander():
# If wander duration expired, choose next task
if t - task_wander.wander_start_time > task_wander.wander_duration:
choose_next_task()
return
if task_wander.pause_start_time > 0.0:
if t - task_wander.pause_start_time > task_wander.pause_duration:
task_wander.pause_start_time = -1.0
walk_time = random_range(1.0, 6.0)
max_walk_dist = walk_time * current_speed
new_target = Vector2(random_range(0, screen_w), random_range(0, screen_h))
if distance(position, new_target) > max_walk_dist:
new_target = position + normalize(new_target - position) * max_walk_dist
target_pos = new_target
else:
velocity = Vector2.zero # freeze during pause
else:
if distance(position, target_pos) < GOOD_ENOUGH_DIST:
task_wander.pause_start_time = t
task_wander.pause_duration = random_range(1.0, 2.0)Three stages: SEEKING_MOUSE → DRAGGING_MOUSE_AWAY → DECELERATING
MOUSE_GRAB_DISTANCE = 15.0 # px, beak tip to cursor
MOUSE_SUCC_TIME = 0.06 # seconds to transition clamp rect
MOUSE_DROP_DISTANCE = 30.0 # px, when to release
GIVE_UP_TIME = 9.0 # seconds before goose gives up chasing
STRUGGLE_RANGE = Vector2(3.0, 3.0)def run_nab_mouse():
cursor_pos = get_cursor_pos()
beak_tip = rig.head2_end_point
if stage == SEEKING_MOUSE:
set_speed(CHARGE)
target_pos = cursor_pos - (beak_tip - position)
if distance(beak_tip, cursor_pos) < MOUSE_GRAB_DISTANCE:
original_vector_to_mouse = cursor_pos - beak_tip
grabbed_time = t
# Pick drag destination at least 1.2 charge-seconds away
drag_to = position
while distance(drag_to, position) / 400.0 < 1.2:
drag_to = Vector2(random() * screen_w, random() * screen_h)
target_pos = drag_to
sound.chomp()
stage = DRAGGING_MOUSE_AWAY
if t > chase_start_time + GIVE_UP_TIME:
stage = DECELERATING
elif stage == DRAGGING_MOUSE_AWAY:
if distance(position, target_pos) < MOUSE_DROP_DISTANCE:
release_cursor_clip()
stage = DECELERATING
else:
p = min((t - grabbed_time) / MOUSE_SUCC_TIME, 1.0)
clip_vec = Vector2.lerp(original_vector_to_mouse, STRUGGLE_RANGE, p)
set_cursor_clip(beak_tip.x + clip_vec.x, beak_tip.y + clip_vec.y,
abs(clip_vec.x), abs(clip_vec.y))
elif stage == DECELERATING:
target_pos = position + normalize(velocity) * 5.0
velocity -= normalize(velocity) * current_acceleration * 2.0 * DELTA_TIME
if magnitude(velocity) < 80.0:
release_cursor_clip()
set_task(Task.WANDER)Windows: ctypes.windll.user32.ClipCursor(ctypes.byref(RECT(left, top, right, bottom))). Release with ClipCursor(None).
macOS: No ClipCursor equivalent. Simulate by moving cursor to the center of the clip rect each frame via QCursor.setPos(). Requires Accessibility permission — silently does nothing if not granted (graceful degradation).
Linux: Use XGrabPointer or the macOS simulation approach with Xlib.
The goose keeps up to 2 meme windows and 2 notepad windows on screen simultaneously (4 total). Each type has its own independent pool — a meme and a notepad do not compete. Windows are not auto-closed when the goose fetches a new one; they stay until the user closes them or the goose evicts one.
Full stage sequence:
(if 2 of this type already on screen)
WALKING_TO_EVICT → EVICTING_WINDOW →
WALKING_OFFSCREEN → WAITING_TO_BRING_WINDOW_BACK → DRAGGING_WINDOW_BACK
When fewer than 2 of this type are on screen, the eviction stages are skipped and the flow starts at WALKING_OFFSCREEN.
WAIT_TIME_MIN = 2.0
WAIT_TIME_MAX = 3.5When 2 windows of the target type are already on screen, the goose picks one at random to evict before fetching the new window.
Walk target: The goose walks to the grab edge of the evict window (right edge for a left-side window, left edge for a right-side window), not the center. This avoids a snap when dragging begins.
if window_center_x < screen_w / 2:
evict_offset = Vector2(window_width, window_height / 2) # beak at right edge
grab_x = window.pos().x + window_width
else:
evict_offset = Vector2(0, window_height / 2) # beak at left edge
grab_x = window.pos().x
target_pos = Vector2(grab_x, window_center_y)
stage = WALKING_TO_EVICTEviction drag: Once the goose reaches the grab edge, it extends its neck, attaches the window to its beak (same move_threadsafe mechanism as normal dragging), and walks toward the nearest screen edge (−80px past left or +80px past right). When the goose goes offscreen, the evict window is hidden and deleted; the goose then transitions to WALKING_OFFSCREEN for the new window.
The evicted window's closing signal is disconnected before hiding to prevent any anger callback from firing — the goose is the one removing it.
def _set_target_offscreen() -> ScreenDirection:
if position.x > screen_w / 2:
target_pos = Vector2(screen_w + 50, lerp(position.y, screen_h / 2, 0.4))
return ScreenDirection.RIGHT
else:
target_pos = Vector2(-50, lerp(position.y, screen_h / 2, 0.4))
return ScreenDirection.LEFTif direction == ScreenDirection.LEFT:
window_offset_to_beak = Vector2(window_width, window_height / 2)
elif direction == ScreenDirection.TOP:
window_offset_to_beak = Vector2(window_width / 2, window_height)
elif direction == ScreenDirection.RIGHT:
window_offset_to_beak = Vector2(0, window_height / 2)The drop target is chosen in WAITING_TO_BRING_WINDOW_BACK:
- First window of its type: placed with wide random spread — up to 300px from the entry edge, full vertical range (clamped to screen bounds).
- Second window of its type: same base position plus a messy offset (50–130px further from edge, ±150px perpendicular jitter). The goose is not neat.
if d == ScreenDirection.LEFT:
tx = w + random_range(15, 300)
ty = random_range(h + 40, screen_h - 60)
elif d == ScreenDirection.RIGHT:
tx = screen_w - (w + random_range(15, 300))
ty = random_range(h + 40, screen_h - 60)
else: # TOP
tx = random_range(w + 60, screen_w - w - 60)
ty = h + random_range(80, 350)
if another_of_same_type_on_screen:
# Extra messy offset
tx ± random_range(50, 130) # further from edge
ty ± random_range(-150, 150) # perpendicular jitterdef run_collect_window():
if stage == WALKING_TO_EVICT:
if distance(position, target_pos) < 15.0:
# Set offscreen target for eviction
stage = EVICTING_WINDOW
elif stage == EVICTING_WINDOW:
if offscreen or evict_window gone:
hide and delete evict_window
direction = _set_target_offscreen()
stage = WALKING_OFFSCREEN
else:
override_extend_neck = True
evict_window.move_threadsafe(beak - evict_window_offset)
elif stage == WALKING_OFFSCREEN:
if distance(position, target_pos) < 5.0:
stage = WAITING_TO_BRING_WINDOW_BACK
elif stage == WAITING_TO_BRING_WINDOW_BACK:
velocity = Vector2.zero
if t - wait_start_time > secs_to_wait:
QMetaObject.invokeMethod(main_window, "show_dialog", QueuedConnection)
main_window.closing.connect(on_window_closed_early)
# compute placement target (see §15.6)
stage = DRAGGING_WINDOW_BACK
elif stage == DRAGGING_WINDOW_BACK:
if distance(position, target_pos) < 5.0:
placed_list.append(main_window)
main_window.closing.connect(placed_window_close_callback)
anger_window = main_window
set_task(Task.WANDER)
return
override_extend_neck = True
main_window.move_threadsafe(beak - window_offset_to_beak)The most recently placed window is the "anger window." If the user closes it:
- Notepad window: goose attacks indefinitely (no timeout)
- Meme window: goose gets angry only within 3 seconds of placing
Both trigger set_task(Task.NAB_MOUSE). Closing any other (non-anger) placed window just removes it from the tracking list with no reprisal. The anger window reference updates whenever a new window is placed.
Three stages: DECIDE_TO_RUN → RUNNING_OFFSCREEN → RUNNING_WANDERING
TRACK_MUD_DURATION = 15.0 # seconds of mud tracking
DIR_CHANGE_INTERVAL = 100.0 # seconds (effectively never changes direction)
AMOK_DURATION = 2.0 # seconds of manic running before slowingdef run_track_mud():
if stage == DECIDE_TO_RUN:
_set_target_offscreen()
set_speed(RUN)
stage = RUNNING_OFFSCREEN
elif stage == RUNNING_OFFSCREEN:
if distance(position, target_pos) < 5.0:
target_pos = Vector2(random(0, screen_w), random(0, screen_h))
next_dir_change_time = t + DIR_CHANGE_INTERVAL
time_to_stop_running = t + AMOK_DURATION
track_mud_end_time = t + TRACK_MUD_DURATION
stage = RUNNING_WANDERING
sound.play_mud_squish()
elif stage == RUNNING_WANDERING:
if distance(position, target_pos) < 5.0 or t > next_dir_change_time:
target_pos = Vector2(random(0, screen_w), random(0, screen_h))
next_dir_change_time = t + DIR_CHANGE_INTERVAL
if t > time_to_stop_running:
target_pos = Vector2(
clamp(position.x + 30.0, 55.0, screen_w - 55.0),
clamp(position.y + 3.0, 80.0, screen_h - 80.0),
)
set_task(Task.WANDER, honk=False)track_mud_end_time is a float initialized to -1.0. Footmarks are added only while t < track_mud_end_time.
The goose sits near the cursor and watches it. Transitions between sub-states every few seconds.
WATCH_MOUSE_DURATION_MIN = 8.0
WATCH_MOUSE_DURATION_MAX = 180.0
BOB_INTERVAL_MIN = 1.2
BOB_INTERVAL_MAX = 3.5
BOB_DURATION = 0.35
WATCH_HONK_INTERVAL_MIN = 5.0
WATCH_HONK_INTERVAL_MAX = 12.0
WATCH_SUB_DURATION_MIN = 2.0
WATCH_SUB_DURATION_MAX = 5.0
SIT_MIN_DURATION = 15.0 # goose will not leave SIT state before this long
class WatchSubState(Enum):
STAND_STILL = "stand_still"
WALK_SLOW = "walk_slow"
SIT = "sit"
@dataclass
class WatchMouseState:
start_time: float
duration: float
next_bob_time: float
next_honk_time: float
bob_end_time: float = -1.0
sub_state: WatchSubState = WatchSubState.WALK_SLOW
next_sub_change_time: float = 0.0
sit_entered_time: float = -1.0Behavior per sub-state:
STAND_STILL:_freeze_position = True, standing poseWALK_SLOW: walk toward cursor at WALK speed ifdist > 60px, otherwise freezeSIT:_freeze_position = True,_target_sit_lerp = 1.0(crouches down)
Head bob: Every BOB_INTERVAL_MIN–BOB_INTERVAL_MAX seconds (except while SIT), override neck extension for BOB_DURATION seconds to do a quick head bob.
Rare honk: Every WATCH_HONK_INTERVAL_MIN–WATCH_HONK_INTERVAL_MAX seconds, 30% chance to actually honk.
Petting while sitting: 70% honk, 20% wander away, 10% attack mouse.
Always faces cursor by setting target_pos = position + normalize(cursor - position) * 50.0.
The goose rushes to the cursor's preferred distance, then follows it. Can flee if cursor gets too close.
FOLLOW_PREFERRED_DIST_MIN = 90.0
FOLLOW_PREFERRED_DIST_MAX = 160.0
FOLLOW_FLEE_DIST = 45.0
FOLLOW_FLEE_DURATION = 1.5
FOLLOW_BOREDOM_MIN = 15.0
FOLLOW_BOREDOM_MAX = 30.0
FOLLOW_SNAP_GRAB_CHANCE = 0.05 # chance to transition to NAB_MOUSE on boredom
HONK_MARCH_CHANCE = 0.12 # chance per check to start a honk march
HONK_MARCH_CHECK_INTERVAL = 10.0
HONK_MARCH_DURATION = 2.5
HONK_MARCH_RATE = 0.38 # seconds between honks during march
class FollowMouseStage(Enum):
RUSHING = "rushing"
FOLLOWING = "following"
FLEEING = "fleeing"Stages:
RUSHING: CHARGE toward cursor's preferred distance; transitions to FOLLOWING once in rangeFOLLOWING: Maintains preferred distance. Freezes when in comfortable zone (±35px deadband). Flees if cursor < 45px away. Occasionally starts a honk march (rapid honking for 2.5s). Returns to WALKING if drifts > preferred_dist + deadband.FLEEING: Runs 180px away from cursor at RUN speed for 1.5s, then returns to FOLLOWING
Boredom: After 15–30 seconds, 5% chance to snap into NAB_MOUSE, otherwise wander.
The goose crouches low (crawl pose) and sneaks toward the cursor. Pounces and grabs when close enough.
SNEAK_STRIKE_DIST = 65.0 # px to trigger pounce
SNEAK_MAX_DURATION = 44.0 # seconds before giving up
SNEAK_HONK_RATE = 0.32 # seconds between honks during pounce/drag
class SneakAttackStage(Enum):
SNEAKING = "sneaking"
POUNCING = "pouncing"
DRAGGING = "dragging"
DECELERATING = "decelerating"Stages:
SNEAKING: SNEAK speed,_target_sit_lerp = 1.0,_target_neck_tuck = 1.0. Creeps toward cursor. When withinSNEAK_STRIKE_DIST, snap to standing pose and switch to POUNCING.POUNCING: CHARGE speed. Chase cursor like NabMouse SEEKING. Honk everySNEAK_HONK_RATE. Grab onMOUSE_GRAB_DISTANCE. If still not grabbed afterSNEAK_MAX_DURATIONextra time, give up to WANDER.DRAGGING: Same as NabMouse DRAGGING_MOUSE_AWAY. Continue honking. Release atMOUSE_DROP_DISTANCE.DECELERATING: Same as NabMouse DECELERATING.
The goose walks to a corner of the screen, circles down in a shrinking spiral, settles, and sleeps. Has a 15% chance of fake sleep (eyes open periodically, panics if spotted).
SLEEP_CIRCLE_RADIUS = 88.0
SLEEP_SETTLE_DURATION = 2.2
SLEEP_MIN_DURATION = 90.0 # seconds minimum sleep
SLEEP_MAX_DURATION = 480.0 # seconds maximum sleep (8 minutes)
SLEEP_CORNER_MARGIN = 165.0 # px inset from screen edge for nest position
class SleepStage(Enum):
WALKING_TO_CORNER = "walking_to_corner"
CIRCLING = "circling"
SETTLING = "settling"
SLEEPING = "sleeping"
@dataclass
class SleepState:
nest_pos: Vector2
spiral_start_angle: float = 0.0 # random start angle (radians)
stage: SleepStage = SleepStage.WALKING_TO_CORNER
spiral_t: float = 0.0 # 0→1 parametric progress along spiral
settle_start_time: float = -1.0
wake_time: float = -1.0
is_fake_sleep: bool = False
next_eye_event_time: float = -1.0 # when to open/close peeking eye
eye_is_open: bool = False
spotted_time: float = -1.0 # when cursor was spotted (triggers freak-out)Stages:
WALKING_TO_CORNER: WALK speed toward one of three corners (top-left, top-right, bottom-right), each inset by SLEEP_CORNER_MARGIN px. Corner chosen randomly (or top-left in DEV mode). Slight random jitter (±15px) added to nest position.
CIRCLING: SNEAK speed. Target advances along a shrinking spiral at constant arc speed (40px/s), ensuring the goose always has a target slightly ahead of itself without overshooting. The spiral runs for 1.5 revolutions. Formula:
arc_len = max(SLEEP_CIRCLE_RADIUS * (1.0 - spiral_t) * 1.5 * 2 * pi, 1.0)
spiral_t = min(spiral_t + (40.0 / arc_len) * DELTA_TIME, 1.0)
angle = spiral_start_angle + spiral_t * 1.5 * 2 * pi
radius = SLEEP_CIRCLE_RADIUS * (1.0 - spiral_t)
target_pos = nest_pos + Vector2(cos(angle) * radius, sin(angle) * radius)
# Transition to SETTLING when spiral_t >= 0.6SETTLING: Freeze position. Over SLEEP_SETTLE_DURATION (2.2s), lerp _target_sit_lerp and _target_neck_tuck from 0→1 (goose crouches down and tucks head). At end, roll 15% chance for fake sleep.
SLEEPING: Freeze position, maintain full crawl pose. Wake after wake_time.
Fake sleep behavior (is_fake_sleep=True):
- No sleep bubbles rendered
- Every 5–15 seconds: open one eye (random left=1 or right=2) for 0.5–2.5 seconds, then close
- While any eye is open and cursor is within 150px: start spotted sequence
- 0–0.75s: hold still (one eye open, no exclamation)
- 0.75–1.5s: open both eyes (
peek_eye = 3) + show exclamation mark -
1.5s: trigger freak-out (see §9.4)
Real sleep behavior (is_fake_sleep=False):
- Sleep bubbles rendered (
rig.show_sleep_bubbles = True) - Clicking the goose wakes it up (honk, transition to WANDER)
After a fake-sleep freak-out, the goose returns to the nearest screen edge, peeks back in cautiously, sweeps its gaze, then walks back onto the screen normally.
PEEK_INSET = 14.0 # px from screen edge for the peek position
@dataclass
class PeekBackState:
peek_pos: Vector2 # position just inside screen edge
enter_pos: Vector2 # position to walk toward when fully returning
face_dir: float # direction facing inward (0=right, 180=left, 90=down, -90=up)
sweep_deg: float # total sweep angle (45–150 degrees)
stage: str = "peeking_in"
look_start_time: float = -1.0
look_duration: float = 8.8 # seconds for the sweep
walk_in_dist: float = -1.0 # measured on first frame of walking_in
pause_start_time: float = -1.0
pause_duration: float = 0.0Setup (in _set_task):
- Find nearest screen edge
peek_pos: 14px inside that edge, clamped 80px from perpendicular edgesenter_pos: random distance into the screen (150px to screen_w/2 or similar), with ±80–180px perpendicular diagonal offset (random direction), clamped to screen boundsface_dir: 0° for left edge, 180° for right edge, 90° for top edge, -90° for bottom edgesweep_deg:random_range(45.0, 150.0)- Rig snapped immediately to full crawl:
rig.sit_lerp_percent = 1.0,rig.neck_tuck_lerp_percent = 1.0
Stages:
peeking_in: Walk topeek_pos. WALK speed until within 80px, then SNEAK speed. Full crawl pose. Transition tolookingwhen within 12px.looking: Freeze position. Hold full crawl pose. Sweep gaze left/right using sine wave overlook_durationseconds. The sweep uses a smoothstep ease-in/ease-out envelope over the first/last 18% of the duration, with linear exit blend toenter_posdirection in the final 18%:t_norm = clamp(elapsed / look_duration, 0.0, 1.0) ramp = 0.18 ease_in = smoothstep(t_norm / ramp) ease_out = smoothstep((1.0 - t_norm) / ramp) envelope = min(ease_in, ease_out) sweep = sin(t_norm * 2π) * (sweep_deg / 2.0) * envelope target_dir = face_dir + sweep exit_blend = linear clamp of last ramp fraction target_pos = lerp(sweep_point, enter_pos, exit_blend)
walking_in: SNEAK speed. Walk toenter_pos. Gradually stand up:_target_sit_lerpand_target_neck_tucklerp from 1→0 proportional to distance remaining. Transition topausingwhen within 12px.pausing: Freeze 0.5–1.5 seconds, then transition to WANDER.
BUILTIN_PHRASES = [
"am goose hjonk",
"good work",
"nsfdafdsaafsdjl\nasdas sorry\nhard to type withh feet",
"i cause problems on purpose",
'"peace was never an option"\n -the goose (me)',
"\n\n >o) \n (_>",
]Load all .txt files from assets/text/notepad_messages/. Merge with built-in phrases into a single Deck. If directory doesn't exist or is empty, use only built-in phrases.
The Deck is module-level and persists across NotepadWindow instances so the goose cycles through all phrases before repeating, even across multiple windows in the same session.
- Size: 200×150 px
- Title:
Goose "Not-epad" - Contains a multiline text box filling the client area
- Font: custom handwriting font loaded from
assets/fonts/. Search for TTF/OTF files; prefer any font whose family name contains "fonty" or "notestar". Fall back to system default if none found. - Font size: from
config.notepad_font_size(default 25) TopMost = True
# On startup, scan assets/fonts/ for .ttf/.otf files
# Load via QFontDatabase.addApplicationFont()
# Prefer font with family name containing "fonty" or "notestar" (case-insensitive)
# Apply to notepad QTextEdit via QFont(family_name, font_size)Load all files from assets/images/memes/. Supported formats: PNG, JPG, JPEG, GIF, BMP, WEBP.
Use a Deck for selection (no repeats until all shown). The Deck is module-level and persists across MemeWindow instances so the goose cycles through all images before repeating, even across multiple windows in the same session.
GIFs must animate — use QMovie for animated GIF playback inside a QLabel.
- Size: 400×400 px
- No title bar text
- Image displayed with
Qt.AspectRatioMode.KeepAspectRatio TopMost = True- Not resizable
Both NotepadWindow and MemeWindow inherit from this.
class MovableWindow(QWidget):
closing = pyqtSignal() # emitted when user closes window
def move_threadsafe(self, x: int, y: int):
QMetaObject.invokeMethod(self, "_do_move", QueuedConnection,
Q_ARG(int, x), Q_ARG(int, y))
@pyqtSlot(int, int)
def _do_move(self, x, y):
self.move(x, y)
def closeEvent(self, event):
self.closing.emit()
super().closeEvent(event)NotepadWindow uses standard window chrome. MemeWindow is frameless.
assets/sounds/Honk1.mp3 through Honk4.mp3
assets/sounds/BITE.mp3
assets/sounds/MudSquish.mp3
assets/sounds/Pat1.wav
assets/sounds/Pat2.wav
assets/sounds/Pat3.wav
assets/sounds/Music.mp3 (optional — loop at 50% volume from startup)
class Sound:
def init(self): ... # pygame.mixer.init(), load/preload all files
def honk(self):
# Pick random from Honk1–4, play at volume 0.8
# Stop previous honk before playing new one
def chomp(self):
# Play BITE.mp3 at volume 0.07
def play_pat(self):
# Pick random from Pat1–3 WAV pool, rewind to start, play
def play_mud_squish(self):
# Seek MudSquish.mp3 to start and playIf config.silence_sounds = True, all Sound methods are no-ops.
Looping Music.mp3 through QMediaPlayer costs ~2% of a CPU core for the whole
session (continuous MP3 decode) — it was the entire measured CPU floor of an
otherwise-idle goose. On first run, Sound decodes the MP3 once in the
background (QAudioDecoder → Int16 PCM) and writes assets/music_cache.wav
(user-data side, gitignored, ~24 MB) via an atomic temp-file rename. Subsequent
sessions feed the WAV to the same QMediaPlayer pipeline — identical audio,
volume, and looping, with near-zero decode cost (static goose: ~2.0% → ~0.5% of
a core). The cache is only written if the decoder honours Int16; any failure or
a corrupt/missing cache silently falls back to the MP3 path. First session is
behaviourally identical to before (MP3 plays immediately while the cache builds).
Hold ESC for ~5 seconds to quit. Progress bar slides in from top.
QUIT_ALPHA_INCREMENT = 0.00216666679 # per frame while ESC held
QUIT_ALPHA_DECREMENT = 0.0166666675 # per frame while ESC released
QUIT_THRESHOLD = 0.99
QUIT_SHOW_THRESHOLD = 0.2def update_quit(painter, keys_pressed):
if Key_Escape in keys_pressed:
quit_alpha += QUIT_ALPHA_INCREMENT
else:
quit_alpha -= QUIT_ALPHA_DECREMENT
quit_alpha = clamp(quit_alpha, 0.0, 1.0)
if quit_alpha > QUIT_SHOW_THRESHOLD:
frac = (quit_alpha - 0.2) / 0.8
y = int(lerp(-15, 10, exponential_ease_out(frac * 2)))
# LightBlue background, LightPink progress fill, dark text
text = "Continue holding ESC to evict goose"
if quit_alpha > QUIT_THRESHOLD:
QApplication.quit()mods/
└── MyModName/
├── mod.py # Required. Must contain a class named Mod.
└── assets/ # Optional.
class Mod:
def __init__(self, api: GooseAPI): ...
def on_load(self): ...
def on_tick(self): ...
def on_render(self, painter: QPainter): ...
def on_task_changed(self, task: Task): ...
def on_unload(self): ...All methods are optional — mod_loader checks hasattr before calling.
class GooseAPI:
@property
def position(self) -> Vector2: ...
@property
def velocity(self) -> Vector2: ...
@property
def direction(self) -> float: ...
@property
def current_task(self) -> Task: ...
@property
def screen_size(self) -> tuple[int, int]: ...
@property
def rig(self) -> Rig: ...
def set_task(self, task: Task): ...
def play_sound(self, path: str): ...
def show_image_window(self, image_path: str): ...
def show_text_window(self, text: str): ...
def add_foot_mark(self, position: Vector2): ...
def draw_circle(self, painter, center: Vector2, radius: int, color: str): ...
def draw_line(self, painter, start: Vector2, end: Vector2, width: int, color: str): ...Mods load only when config.enable_mods = True. Loaded alphabetically. Bad mods log an error and are skipped — never crash the app.
Three flags in config.ini for testing specific behaviors without waiting for them to appear naturally. Edit the file and restart — do not hardcode these in source.
DEV_ForceTask = collect_window_notepad ; force a specific task every time (blank to disable)
DEV_ShortWander = True ; wander lasts only 3 seconds
DEV_ForceFakeSleep = True ; always fake sleep instead of 15% chanceDEV_ForceTask is checked in _choose_next_task() — if set, bypasses the deck entirely.
DEV_ShortWander is checked in _get_random_wander_duration().
DEV_ForceFakeSleep is checked during the SETTLING→SLEEPING transition.
Additionally, when DEV_ForceTask is set, the SLEEP task always uses the top-left corner instead of a random corner.
Clear all three before pushing a stable version tag.
1. Create QApplication
2. Detect platform; if Wayland and no XWayland: show error dialog and exit
3. Load config.ini (create silently with defaults if missing)
4. Create overlay window (transparent, always-on-top, click-through)
5. Size overlay to primary monitor
6. Apply platform-specific click-through (Windows: SetWindowLong)
7. Init sound system (unless SilenceSounds=True)
8. Init goose (TheGoose.__init__):
a. Set initial position to (-20, 120)
b. Set initial target to (100, 150)
c. Init foot positions
d. Set initial task to WANDER (with honk=False)
9. If EnableMods=True: discover and load mods
10. Start game loop timer
11. QApplication.exec()
- Click-through: must call
SetWindowLongvia ctypes after window show. - Cursor clip:
ClipCursorvia ctypes,RECTstruct. - Bring to foreground:
SetForegroundWindow(hwnd)when goose grabs cursor.
- Click-through:
WA_TransparentForMouseEventsis not enough alone — also callNSApp.windows()[n].setIgnoresMouseEvents_(True)via PyObjC aftershow(). - Accessibility permissions required for cursor move (
QCursor.setPos()). Check withQuartz.AXIsProcessTrusted()on startup and show a dialog if not granted. - If
pyobjc-framework-Quartzis not installed, show a dialog on startup telling the user topip install pyobjc-framework-Quartz. Goose still runs without it — mouse stealing just won't work. - No
ClipCursorequivalent — simulate by callingQCursor.setPos()to the clip rect center each frame.
- Check for
DISPLAYenv var; abort with message if not set. pyautoguiusespython-xlib— ensure it's in requirements.
| File | Description |
|---|---|
| Honk1–4.mp3 | Goose honk sounds (4 variants) |
| BITE.mp3 | Cursor grab sound (very quiet, 0.07 volume) |
| MudSquish.mp3 | Mud footstep squish sound |
| Pat1–3.wav | Footstep pat sounds (3 variants) |
| Music.mp3 | Optional background music loop |
App must not crash if sound files are missing — log a warning and continue.
| # | Checkpoint | Deliverable |
|---|---|---|
| 1 | Engine layer | vector2.py, math_utils.py, easings.py, deck.py, rig.py, time_keeper.py — with unit tests |
| 2 | Transparent overlay window | Window covers screen, is transparent, click-through on all 3 platforms. |
| 3 | Goose rig + renderer | Goose drawn correctly at a fixed position. All body parts correct sizes/colors. Crawl/sit pose works. |
| 4 | Physics + wander | Goose walks around screen autonomously. Feet animate. Neck extends at run speed. |
| 5 | Footmarks | TrackMud task works. Brown dots appear and fade. |
| 6 | NabMouse | Cursor gets grabbed and dragged. All 3 platforms. |
| 7 | Notepad window | Window spawns with custom font. Goose drags it. Early close triggers NabMouse. |
| 8 | Meme window | Image/GIF window spawns. GIF animates. |
| 9 | WatchMouse | Goose sits near cursor, bobs, occasionally honks. Petting while sitting has three outcomes. |
| 10 | FollowMouse | Goose follows cursor at preferred distance. Flees if too close. Honk marches. |
| 11 | SneakAttack | Goose creeps in crawl pose, pounces, grabs. |
| 12 | Sleep | Goose circles to corner, settles, sleeps. Fake sleep with eye peeking and freak-out. |
| 13 | PeekBack | Post-freak-out peek sequence with sweep and walk-in. |
| 14 | Sound | All sounds play. Silence mode works. |
| 15 | Config | config.ini loads/saves. Custom colors work. |
| 16 | Quit mechanic | ESC hold progress bar. Correct timing. |
| 17 | Mod API | Loader discovers mods. Example mod runs. Bad mods don't crash app. |
| 18 | Packaging | PyInstaller builds working single-folder distributions for Windows, macOS, Linux. |
| Feature | Original | PyGoose |
|---|---|---|
| Donate window | Shows after 480s | Omitted |
| Config file name | config.goos |
config.ini |
| Sound backend | WinMM mciSendString |
pygame.mixer |
| Cursor clip on macOS | Not supported (Windows-only) | Simulated with QCursor.setPos() each frame; requires Accessibility permission |
| Mod format | C# DLLs | Python plugins |
| First UX sequence | TrackMud → Meme hardcoded | Same behavior, cleaner implementation |
| Linux support | None | X11 supported |
| New tasks | None | WatchMouse, FollowMouse, SneakAttack, Sleep/FakeSleep, PeekBack |
| Config missing | Show messagebox | Create silently |
| Notepad font | System default | Custom handwriting font from assets/fonts/ |
The goal is a double-click-to-run experience for users who have no Python installed. PyInstaller bundles the interpreter, all dependencies, and assets into a self-contained output.
Windows and Linux: Use one-folder (--onedir):
- A folder containing the executable plus DLLs, Qt platform plugins, and the assets tree
- Startup is instant (no extraction step)
- User zips the folder and shares it, or just hands over the folder
macOS: Use one-file (--onefile):
- Produces a single
PyGoosebinary - macOS Gatekeeper quarantines each file individually in a one-folder build, requiring the user to approve every DLL and dylib. A single binary means one approval and it runs.
- Trade-off: each launch extracts ~80MB to a temp directory (~2–3 second delay on first launch after a cold boot). Acceptable for macOS given the Gatekeeper UX problem it solves.
The PyGoose.spec is platform-aware: is_mac = sys.platform == 'darwin' gates which output mode is used.
When PyInstaller bundles a one-folder build, the working directory at runtime is not the same as where the exe lives. All asset paths must go through a helper:
import sys, os
def resource_path(relative: str) -> str:
base = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, relative)Every place in the code that opens a file from assets/ must use resource_path("assets/...") instead of a bare relative path. This applies to: sound loading, font loading, meme images, notepad text files.
config.ini must not be bundled inside the PyInstaller package — it needs to live next to the exe so users can edit it. At startup, resolve the config path relative to the exe's actual location, not _MEIPASS:
def config_path() -> str:
if getattr(sys, 'frozen', False):
# Running as PyInstaller bundle — config lives next to the exe
return os.path.join(os.path.dirname(sys.executable), 'config.ini')
else:
# Running from source
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.ini')Similarly, assets/text/notepad_messages/ and assets/images/memes/ are user-drop folders — they should also live next to the exe, not inside the bundle, so users can add their own content without re-packaging. Two separate path roots: resource_path() for read-only bundled assets (sounds, fonts), and user_data_path() (exe-relative) for user-editable content (config, memes, notes).
A .spec file should be committed to the repo rather than relying on CLI flags. Key entries:
# PyGoose.spec (sketch)
a = Analysis(
['main.py'],
datas=[
('assets/sounds', 'assets/sounds'),
('assets/fonts', 'assets/fonts'),
# memes and notepad_messages are user-side — do NOT bundle them
],
hiddenimports=[
'pygame',
'PyQt6.sip',
],
)Qt platform plugins (specifically qwindows.dll / qcocoa.dylib / qxcb.so) must be included or the window will not open. PyInstaller's PyQt6 hook usually handles this automatically, but verify in testing.
| Issue | Cause | Fix |
|---|---|---|
| Blank screen / no window | Qt platform plugin missing | Ensure platforms/ folder is in the bundle; add --collect-all PyQt6 if needed |
| No sound | pygame SDL DLLs missing | --collect-all pygame or manually include SDL2.dll |
| Assets not found | Bare relative paths | Replace all asset opens with resource_path() |
| Config resets on every run | config.ini inside bundle (read-only) | Use config_path() pointing to exe directory |
| Antivirus flags the exe | PyInstaller one-file signature | Use one-folder build; code-sign the exe for distribution |
| Slow startup | One-file extraction | Switch to one-folder |
A simple build.py or build.bat at the repo root should encapsulate the build command so it's repeatable:
:: build.bat
pyinstaller PyGoose.spec --noconfirm --cleanOutput lands in dist/PyGoose/. The build script should also copy a blank config.ini (with defaults) and empty assets/images/memes/ and assets/text/notepad_messages/ folders into dist/PyGoose/ so the distribution is ready to use out of the box.
- Replace all bare
open("assets/...")calls withresource_path() - Add
config_path()helper and thread it throughGooseConfig - Add
user_data_path()helper for memes and notepad messages - Write
PyGoose.spec - Write
build.bat/build.sh - Test on a clean Windows machine with no Python installed
Feature backlog is maintained in CLAUDE.md (local only, not in git). Items move here once implemented.
The goose keeps up to 2 notepad and 2 meme windows on screen (per type, independent pools). When a 3rd would be added, a random existing window of that type is evicted first — the goose walks to its edge, grabs it, drags it offscreen, and then fetches the new window normally. See §15 for full implementation details.
The debug visualization system built into PyGoose (§12.6.10) will eventually be extracted into its own standalone Python GUI application — a prop design tool that runs independently from PyGoose. The goal is to make adding new props accessible without requiring an AI or deep familiarity with the codebase.
Motivation: The current process (edit Python, restart goose, iterate visually) works well but has friction. A dedicated tool could offer: a live canvas where you draw and adjust shapes, sliders for carry offset and collision radius, instant preview of the prop at all 8 compass directions, export of the generated render code and PropDef registry entry directly into PyGoose's prop files.
When to build: after several props have been implemented together and the patterns are fully understood. Each new prop implementation adds to the knowledge of what the tool needs to handle. The tool should be built from experience, not speculation.
What it replaces: _render_debug_box, _render_compass_box, and the DEV_ForceSpawnProp flow — those become the reference implementation that the standalone tool is modeled on.
See CLAUDE.md → Pending fixes → "Git push hook" for the planned PreToolUse hook that will enforce the no-unauthorized-push rule at the harness level.
Active bug tracking lives in CLAUDE.md → Pending fixes. Items move here once resolved.