The lasers in Laser Beast are one of my favorite parts of the game. Visually, they are the thing I am most happy with. The system has four laser types, a shared visual layer, and a button system that lets players disable specific lasers mid-level. This post breaks down how it works, why I built it the way I did, and what I would change.

What the Laser System Needs to Do

Before writing any code, I listed the requirements:

  • Fire a raycast in a direction and detect the player
  • Render a jagged line with particle effects at the endpoint
  • Support four distinct movement or timing behaviors
  • Let a button toggle specific laser types on and off
  • Respect player settings (particles on/off)

That list drove every decision below.

The Core: One Class That Fires a Laser

LaserCore is the foundation. Every laser in the game has one. Its job is narrow: fire a raycast, draw the line, check for collisions.

private Vector3 FireRaycast()
{
    RaycastHit2D hit = Physics2D.Raycast(transform.position, _direction, Mathf.Infinity, collisionLayer);
    Vector3 endPosition = _direction * 1000f;

    if (hit.collider != null)
        endPosition = hit.point - (Vector2)transform.position;

    return endPosition;
}

It fires from the object’s position in _direction. That direction is set exclusively through a public method. LaserCore does not decide where it points. It just points where it’s told.

Why Direction Is Separated From Position

Two of the four laser types move the laser. They do it in completely different ways.

  • SlidingLaser moves the GameObject itself. The laser origin changes.
  • SweepingLaser changes the direction vector. The origin stays fixed.

If LaserCore owned its own direction logic, adding a sweeping behavior would mean modifying a class that already works. Instead, SweepingLaser calls laserCore.SetDirection() every frame. LaserCore never needs to change.

The Four Laser Types

Each type is a separate component. They attach alongside LaserCore and extend its behavior without touching it.

Static

Static lasers have no extra script. LaserCore handles everything. The laser points in whatever direction the GameObject is rotated. It never moves.

There is no StaticLaser.cs. I did not create one for the sake of consistency. A script that does nothing is worse than no script.

Sliding

SlidingLaser moves the entire GameObject between two positions. A relativeOffset vector defines the endpoint relative to the start. Gizmos in the Scene view show both positions as colored spheres, so placement does not require running the game.

The core movement logic looks like this:

Vector2 newPosition = movingToTarget
    ? Vector2.Lerp(startPosition, targetPosition, fractionOfDistance)
    : Vector2.Lerp(targetPosition, startPosition, fractionOfDistance);

The moveBackAndForth flag controls which branch runs. With it off, the laser travels from start to end once and the script disables itself. With it on, it loops indefinitely. That one flag covers two distinct level design uses without any extra code.

The laser origin moves with the object. LaserCore does not know or care.

Sweeping

SweepingLaser rotates the laser direction using a sine wave. The GameObject does not move.

float sweepOffset = Mathf.Sin(elapsedTime * sweepSpeed) * sweepAngle;
Vector3 sweepDirection = Quaternion.Euler(0f, 0f, baseRotationZ + sweepOffset) * Vector3.up;
laserCore.SetDirection(sweepDirection);

sweepAngle controls how wide the arc is. sweepSpeed controls how fast it oscillates. The reverseSweep flag flips the phase. These three values let me tune the threat level of a sweeping laser entirely from the Inspector.

Pulse

PulseLaser manages an on/off cycle with configurable durations and a start delay.

if (isLaserOn && timer <= 0f)
{
    laserCore.ToggleLaser(false);
    isLaserOn = false;
    timer = offDuration;
}
else if (!isLaserOn && timer <= 0f)
{
    laserCore.ToggleLaser(true);
    isLaserOn = true;
    timer = onDuration;
}

Before the main cycle starts, startDelay holds the laser in an off state for a set number of seconds.

if (delayTimer > 0f)
{
    delayTimer -= Time.deltaTime;
    return;
}

This is the most useful field in the script. I can place three pulse lasers in the same room, give each one a different startDelay, and they naturally fall into a staggered sequence. Laser 1 fires while laser 2 is off. Laser 2 fires while laser 3 is off. The result looks like a coordinated pattern, but each laser is just running its own independent timer. No coordination logic required.

PulseLaser also exposes PauseAndReset() and ResumeFromStart(). These matter because of how the button system works. When a button disables a pulse laser, it calls PauseAndReset(), which stops the cycle entirely. When the button releases, ResumeFromStart() restarts the laser from the beginning of its delay. Without this, every pulse laser in the room would snap back on at the same moment and run in sync with each other. The stagger would be gone. ResumeFromStart() preserves it.

How Lasers Get Disabled: Events Over References

I did not want to wire every button to a list of laser references in the Inspector. That approach means every time I add a laser to a room, I have to update the button too. I did not want the button coupled to specific laser instances at all.

Instead, the system uses an event with a LaserType parameter.

  • LaserButton fires a LaserButtonPressed event and passes its assigned LaserType.
  • LaserDisabler listens for that event on every laser.
  • If the event’s LaserType matches the laser’s own type, the laser disables itself.
private void HandleButtonEvent(object parameter)
{
    if (parameter is not LaserType triggeredType) return;

    if (triggeredType == core.LaserType)
        DisableSelf();
    else
        MaybeReenable();
}

The button does not know which lasers it controls. The lasers decide for themselves. Adding a new laser to a room that already has a button requires zero changes to the button.

Reactivation and Button State

The reactivation timer in LaserDisabler does not count down while the player is still on the button. The LaserButton class tracks this with a static list of all active buttons, so any disabler can check it at any time.

while (elapsed < reactivationDelay)
{
    if (LaserButton.IsPlayerOnAnyButton(core.LaserType))
        yield return null;
    else
    {
        elapsed += Time.deltaTime;
        yield return null;
    }
}

This prevents a laser from snapping back on while the player is still standing on a button.

One thing worth noting: PulseLaser and LaserDisabler both call laserCore.ToggleLaser() directly. That is internal coupling between components that always live on the same GameObject, so it is not a problem in practice. An event-driven toggle is possible, but it adds indirection without much benefit when the caller and receiver are always co-located.

Visual Feedback as a Separate Layer

LaserVisuals runs independently of everything else. It reads the line renderer’s start and end points in LateUpdate and applies effects after LaserCore has already done its work. There are three things happening here.

Jagged Effect

The straight line is subdivided into short segments. Each interior point is nudged perpendicular to the laser’s direction by a random amount.

Vector3 direction = (endPoint - startPoint).normalized;
Vector3 perpendicular = Vector3.Cross(direction, Vector3.forward).normalized;

for (int i = 1; i < segmentCount; i++)
{
    float t = (float)i / segmentCount;
    Vector3 point = Vector3.Lerp(startPoint, endPoint, t);
    point += perpendicular * Random.Range(-jaggedness, jaggedness);
    jaggedPoints[i] = point;
}

The segment count is based on the laser’s length divided by a fixed segment length, so longer lasers get more segments automatically. The jaggedness value controls how chaotic the wobble is. This runs every frame, so the laser never looks static.

Particles

A particle system is instantiated at the laser’s endpoint and tracked there every frame. The color is pulled from the laser’s configured color, so different laser types can be visually distinct. Particles respect the player’s settings via a GameSettingsConfig scriptable object, so players who turn them off get a clean line with no performance cost.

Proximity Haptics

The laser holds a reference to a HapticProximity collider on the player, grabbed automatically when the player spawns. Every frame, LaserCore checks each midpoint of the jagged line against that collider. Only the segments inside the collider are evaluated, and only the closest one is used.

float normalized = Mathf.Clamp01(1f - (closestDistance / maxDistance));
float intensity = Mathf.Pow(normalized, 2f) * 0.5f;
Haptic.Instance?.RegisterProximityIntensity(intensity);

The quadratic curve keeps intensity low at a distance and spikes it sharply as the player gets very close. It gives the player a physical signal that they are near the laser before they actually touch it.

What I Would Do Differently

This system started as a game jam prototype. The original laser was simple: point in a direction, kill the player. By the time I was expanding toward a full release, I had four types and a working system. I thought about redoing the architecture into something more composable, but that opened up questions I did not have answers to.

If a laser had both SlidingLaser and PulseLaser on it, what happens when a button is pressed? Does it respond to both types? Just one? What is its LaserType even supposed to be? Those are not implementation problems. They are design problems. Solving them would have meant making new design decisions on top of redoing existing code that already worked.

So I kept it as it was. One type per laser is a clean constraint for this game. The button system is built around it. The whole thing makes sense because the scope supports it.

If I ever do a sequel with more complex laser puzzles, I would need a different foundation. Treating behaviors as a stack, with a coordinator applying them in sequence, is probably the right model. Each behavior registers itself and the coordinator handles ordering. More infrastructure, but more room to grow.


0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *